feat(app): Campaign CC slice CC4 — chargen screen shell + Heritage/Profession/Skills/Town pages

Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via
CharacterCreationUiController/CharacterCreationUiMountCoordinator,
cloning CharacterManagementUiController's recipe. Master shell ports
SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and
ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random
nav) verbatim, with free tab navigation over all six pages. Heritage,
Profession, Skills, and Town pages bind to CC3's
RuntimeCharacterCreationState commands; Appearance and Summary mount as
content-inert placeholders for CC6b/CC5.

Live-DAT probing (CharacterCreationLiveDatTests) found two widget-
mapping surprises the decomp's DynamicCast hints don't predict: the
Profession slider's value field imports as an editable UiField (wired
for direct numeric entry), and the avail/health/stamina/mana/credits
displays author as UIElement_Button hosts whose Type-12 value child is
swallowed by UiButton.ConsumesDatChildren — substituted with the
button's own Label. No new DatWidgetFactory widget types were needed.

Threads the installed DAT's real ChargenOptions into Runtime via the
new RuntimeCharacterCreationState.InstallOptions, called from
ContentEffectsAudioCompositionPhase.Compose (mirrors
InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty
unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/
ApplyCreationFailed) to SessionStatusWriter for both graphical and
headless hosts, and adds the CharacterCreation view/command seam
through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands
alongside CharacterSelection's existing shape.

Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender-
select interim default, the omitted ToD-account gate, the button-Label
widget substitution, the Random-button approximation, the flat-listbox
Skills simplification, and the Appearance/Summary placeholders.

Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless
165/0 unaffected, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 17:45:51 +02:00
parent 3a6b7e3115
commit 0e71d3b829
27 changed files with 3344 additions and 12 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -12,9 +12,11 @@ using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.Spells;
using AcDream.Core.Vfx;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using DatReaderWriter;
using Silk.NET.Input;
@ -63,6 +65,14 @@ internal sealed record ContentEffectsAudioDependencies(
Action<string> Error)
{
public RuntimeCharacterState Character => Runtime.CharacterOwner;
/// <summary>Campaign CC slice CC4: the character-creation options
/// install target — see <c>ContentEffectsAudioCompositionPhase.Compose</c>'s
/// <c>ChargenOptionsInstalled</c> step and
/// <see cref="RuntimeCharacterCreationState.InstallOptions"/>'s own doc
/// for why this is safe at composition time (strictly before any
/// session's <c>Begin</c>).</summary>
public LiveSessionController Session => Runtime.Session;
}
internal interface IGameWindowContentEffectsAudioPublication
@ -96,6 +106,13 @@ internal interface IContentEffectsAudioCompositionFactory
RuntimeCharacterState character,
MagicCatalog catalog);
int GetSpellCount(MagicCatalog catalog);
/// <summary>Campaign CC slice CC4: mirrors the
/// <see cref="LoadMagicCatalog"/>/<see cref="InstallSpellMetadata"/>
/// pair's "load off dats, install once onto the owning Runtime state"
/// shape for the chargen options
/// (<c>AcDream.Content.CharGen.ChargenTableReader.Load</c>).</summary>
ChargenOptions LoadChargenOptions(IDatReaderWriter dats);
void InstallChargenOptions(LiveSessionController session, ChargenOptions options);
IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,
@ -166,6 +183,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count;
public ChargenOptions LoadChargenOptions(IDatReaderWriter dats) =>
AcDream.Content.CharGen.ChargenTableReader.Load(dats);
public void InstallChargenOptions(LiveSessionController session, ChargenOptions options) =>
session.CharacterCreationState.InstallOptions(options);
public IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,
@ -270,6 +293,7 @@ internal enum ContentEffectsAudioCompositionPoint
PreparedAssetSourcePublished,
MagicCatalogPublished,
SpellMetadataInstalled,
ChargenOptionsInstalled,
AnimationLoaderPublished,
CollisionBuilderPublished,
EmitterRegistryPublished,
@ -362,6 +386,12 @@ internal sealed class ContentEffectsAudioCompositionPhase :
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled);
ChargenOptions chargen = _factory.LoadChargenOptions(dats);
_factory.InstallChargenOptions(_dependencies.Session, chargen);
_dependencies.Log(
$"chargen: loaded {chargen.HeritagesById.Count} heritage(s) from portal.dat");
Fault(ContentEffectsAudioCompositionPoint.ChargenOptionsInstalled);
IAnimationLoader animations = _factory.CreateAnimationLoader(
dats,
_dependencies.ResidencyBudgets.AnimationBytes,

View file

@ -962,6 +962,37 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
// /GameplayWindowCommands wrap this same d.Window.Close
// delegate) — no separate exit path.
d.Window.Close)
: null,
// Campaign CC slice CC4: same late-bound generation-capturing
// seam as CharacterSelection above. RequestExit here is a
// plain presentation action (closing the chargen screen and
// letting character-management's own Tick keep re-drawing
// itself underneath — see CharacterCreationUiController's
// OnExit doc), NOT a Runtime command or a window-close.
CharacterCreation: d.Options.LiveCharacterSelector is null
? new CharacterCreationRuntimeBindings(
() => late.GameRuntime.CharacterCreation,
late.GameRuntime.CharacterCreationSelectHeritage,
late.GameRuntime.CharacterCreationSelectGender,
late.GameRuntime.CharacterCreationSelectTemplate,
late.GameRuntime.CharacterCreationSetAttribute,
late.GameRuntime.CharacterCreationSetAttributeLock,
late.GameRuntime.CharacterCreationTrainSkill,
late.GameRuntime.CharacterCreationSpecializeSkill,
late.GameRuntime.CharacterCreationUntrainSkill,
late.GameRuntime.CharacterCreationSelectStartArea,
late.GameRuntime.CharacterCreationFinish,
RequestExit: () => { },
ResolveText: key =>
{
lock (d.DatLock)
{
return new DatStringResolver(d.Dats).Resolve(
0x23000002u,
DatStringResolver.ComputeHash(key));
}
},
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
: null);
RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings));

View file

@ -56,6 +56,19 @@ internal sealed class DeferredGameRuntimeStateCommands
}
}
/// <summary>Campaign CC slice CC4: same late-bound borrow shape as
/// <see cref="CharacterSelection"/>.</summary>
public IRuntimeCharacterCreationView? CharacterCreation
{
get
{
lock (_gate)
return !_deactivated && _view is not null
? _view.CharacterCreation
: null;
}
}
public IDisposable Bind(
IGameRuntimeView view,
IGameRuntimeCommands commands)
@ -166,6 +179,54 @@ internal sealed class DeferredGameRuntimeStateCommands
Invoke((commands, generation) =>
commands.CharacterSelection.Cancel(generation));
// ── Campaign CC slice CC4: character-creation page commands ─────────
// Same "capture view+commands under one generation" shape as every
// character-selection method above.
public RuntimeCommandResult CharacterCreationSelectHeritage(uint heritageId) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SelectHeritage(generation, heritageId));
public RuntimeCommandResult CharacterCreationSelectGender(uint genderKey) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SelectGender(generation, genderKey));
public RuntimeCommandResult CharacterCreationSelectTemplate(uint templateIndex) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SelectTemplate(generation, templateIndex));
public RuntimeCommandResult CharacterCreationSetAttribute(
ChargenAttributeId attributeId,
int value) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SetAttribute(generation, attributeId, value));
public RuntimeCommandResult CharacterCreationSetAttributeLock(
ChargenAttributeId attributeId,
bool locked) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SetAttributeLock(generation, attributeId, locked));
public RuntimeCommandResult CharacterCreationTrainSkill(uint skillId) =>
Invoke((commands, generation) =>
commands.CharacterCreation.TrainSkill(generation, skillId));
public RuntimeCommandResult CharacterCreationSpecializeSkill(uint skillId) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SpecializeSkill(generation, skillId));
public RuntimeCommandResult CharacterCreationUntrainSkill(uint skillId) =>
Invoke((commands, generation) =>
commands.CharacterCreation.UntrainSkill(generation, skillId));
public RuntimeCommandResult CharacterCreationSelectStartArea(int startAreaIndex) =>
Invoke((commands, generation) =>
commands.CharacterCreation.SelectStartArea(generation, startAreaIndex));
public RuntimeCommandResult CharacterCreationFinish(bool confirmUnspentCredits) =>
Invoke((commands, generation) =>
commands.CharacterCreation.Finish(generation, confirmUnspentCredits));
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
// Same "capture view+commands under one generation" shape as every
// method above — a displaced session (reconnect mid-click) can never

View file

@ -221,7 +221,20 @@ internal sealed class LiveSessionRuntimeFactory
_sessionId,
selection.CharacterId,
selection.CharacterName),
LoginCommands: loginCommands),
LoginCommands: loginCommands,
// Campaign CC slice CC4: the two sibling events to Roster/
// CharacterEntered above — see SessionStatusWriter's own doc
// for why characterCreated precedes an eventual enteredWorld
// rather than replacing it.
CharacterCreated: identity => _statusWriter.CharacterCreated(
_sessionId,
identity.Guid,
identity.Name),
CreationFailed: rejection => _statusWriter.CreationFailed(
_sessionId,
rejection.RawCode,
rejection.Reason,
rejection.AttemptedName)),
connectOptions);
}

View file

@ -1,5 +1,6 @@
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter
private readonly GameRuntime _runtime;
private readonly CurrentGameRuntimeCommandAdapter _commands;
private readonly CharacterSelectionProjection _characterSelection;
private readonly CharacterCreationProjection _characterCreation;
private readonly IDisposable _hostLease;
private readonly object _subscriptionGate = new();
private readonly HashSet<AdapterSubscription> _subscriptions = [];
@ -42,6 +44,7 @@ internal sealed class CurrentGameRuntimeAdapter
try
{
_characterSelection = new CharacterSelectionProjection(this);
_characterCreation = new CharacterCreationProjection(this);
_commands = new CurrentGameRuntimeCommandAdapter(
runtime.Session,
sessionHost,
@ -93,6 +96,8 @@ internal sealed class CurrentGameRuntimeAdapter
public IRuntimeChatView Chat => _runtime.Chat;
public IRuntimeCharacterSelectionView CharacterSelection =>
_characterSelection;
public IRuntimeCharacterCreationView CharacterCreation =>
_characterCreation;
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
public IRuntimeActionView Actions => _runtime.Actions;
@ -105,6 +110,10 @@ internal sealed class CurrentGameRuntimeAdapter
_characterSelection;
IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection =>
_characterSelection;
public IRuntimeCharacterCreationCommands CharacterCreationCommands =>
_characterCreation;
IRuntimeCharacterCreationCommands IGameRuntimeCommands.CharacterCreation =>
_characterCreation;
public IRuntimeSelectionCommands Selection => _commands;
public IRuntimeCombatCommands Combat => _commands;
public IRuntimeMagicCommands Magic => _commands;
@ -281,6 +290,83 @@ internal sealed class CurrentGameRuntimeAdapter
}
}
// ── Campaign CC slice CC4: character creation, same shape as the
// character-selection block above. ────────────────────────────────
private RuntimeCharacterCreationSnapshot CharacterCreationSnapshot()
{
lock (_subscriptionGate)
{
if (IsActive)
return _runtime.CharacterCreation.Snapshot;
return default;
}
}
private ChargenSkillAdvancementClass CharacterCreationSkillLevel(uint skillId)
{
lock (_subscriptionGate)
{
return IsActive
? _runtime.CharacterCreation.GetSkillLevel(skillId)
: ChargenSkillAdvancementClass.Inactive;
}
}
private ChargenOptions CharacterCreationOptions()
{
lock (_subscriptionGate)
{
return IsActive
? _runtime.CharacterCreation.Options
: ChargenOptions.Empty;
}
}
private IDisposable SubscribeCharacterCreation(
IRuntimeCharacterCreationObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_subscriptionGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var gated = new AdapterCharacterCreationObserver(this, observer);
IDisposable runtimeSubscription =
_runtime.CharacterCreation.Subscribe(gated);
var subscription = new AdapterSubscription(
this,
runtimeSubscription);
_subscriptions.Add(subscription);
return subscription;
}
}
private RuntimeCommandResult ExecuteCharacterCreation(
Func<IRuntimeCharacterCreationCommands, RuntimeCommandResult> execute)
{
lock (_subscriptionGate)
{
if (!IsActive)
{
return new RuntimeCommandResult(
RuntimeCommandStatus.Inactive,
_runtime.Generation);
}
return execute(_runtime.Session);
}
}
private void ForwardCharacterCreation(
IRuntimeCharacterCreationObserver observer,
in RuntimeCharacterCreationDelta delta)
{
lock (_subscriptionGate)
{
if (IsActive)
observer.OnCharacterCreationChanged(in delta);
}
}
private sealed class CharacterSelectionProjection(
CurrentGameRuntimeAdapter owner)
: IRuntimeCharacterSelectionView,
@ -350,6 +436,126 @@ internal sealed class CurrentGameRuntimeAdapter
owner.ForwardCharacterSelection(observer, in delta);
}
private sealed class CharacterCreationProjection(
CurrentGameRuntimeAdapter owner)
: IRuntimeCharacterCreationView,
IRuntimeCharacterCreationCommands
{
public RuntimeCharacterCreationSnapshot Snapshot =>
owner.CharacterCreationSnapshot();
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
owner.CharacterCreationSkillLevel(skillId);
public ChargenOptions Options => owner.CharacterCreationOptions();
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) =>
owner.SubscribeCharacterCreation(observer);
public RuntimeCommandResult SelectHeritage(
RuntimeGenerationToken expectedGeneration,
uint heritageId) =>
owner.ExecuteCharacterCreation(
commands => commands.SelectHeritage(expectedGeneration, heritageId));
public RuntimeCommandResult SelectGender(
RuntimeGenerationToken expectedGeneration,
uint genderKey) =>
owner.ExecuteCharacterCreation(
commands => commands.SelectGender(expectedGeneration, genderKey));
public RuntimeCommandResult SelectTemplate(
RuntimeGenerationToken expectedGeneration,
uint templateIndex) =>
owner.ExecuteCharacterCreation(
commands => commands.SelectTemplate(expectedGeneration, templateIndex));
public RuntimeCommandResult SetAttribute(
RuntimeGenerationToken expectedGeneration,
ChargenAttributeId attributeId,
int value) =>
owner.ExecuteCharacterCreation(
commands => commands.SetAttribute(expectedGeneration, attributeId, value));
public RuntimeCommandResult SetAttributeLock(
RuntimeGenerationToken expectedGeneration,
ChargenAttributeId attributeId,
bool locked) =>
owner.ExecuteCharacterCreation(
commands => commands.SetAttributeLock(expectedGeneration, attributeId, locked));
public RuntimeCommandResult TrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId) =>
owner.ExecuteCharacterCreation(
commands => commands.TrainSkill(expectedGeneration, skillId));
public RuntimeCommandResult SpecializeSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId) =>
owner.ExecuteCharacterCreation(
commands => commands.SpecializeSkill(expectedGeneration, skillId));
public RuntimeCommandResult UntrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId) =>
owner.ExecuteCharacterCreation(
commands => commands.UntrainSkill(expectedGeneration, skillId));
public RuntimeCommandResult SetAppearanceIndex(
RuntimeGenerationToken expectedGeneration,
ChargenAppearanceSlot slot,
uint index) =>
owner.ExecuteCharacterCreation(
commands => commands.SetAppearanceIndex(expectedGeneration, slot, index));
public RuntimeCommandResult SetShade(
RuntimeGenerationToken expectedGeneration,
ChargenShadeSlot slot,
double value) =>
owner.ExecuteCharacterCreation(
commands => commands.SetShade(expectedGeneration, slot, value));
public RuntimeCommandResult SelectStartArea(
RuntimeGenerationToken expectedGeneration,
int startAreaIndex) =>
owner.ExecuteCharacterCreation(
commands => commands.SelectStartArea(expectedGeneration, startAreaIndex));
public RuntimeCommandResult SetName(
RuntimeGenerationToken expectedGeneration,
string name) =>
owner.ExecuteCharacterCreation(
commands => commands.SetName(expectedGeneration, name));
public RuntimeCommandResult SetSlot(
RuntimeGenerationToken expectedGeneration,
uint slot) =>
owner.ExecuteCharacterCreation(
commands => commands.SetSlot(expectedGeneration, slot));
public RuntimeCommandResult Finish(
RuntimeGenerationToken expectedGeneration,
bool confirmUnspentCredits = false) =>
owner.ExecuteCharacterCreation(
commands => commands.Finish(expectedGeneration, confirmUnspentCredits));
public RuntimeCommandResult AcknowledgeRejection(
RuntimeGenerationToken expectedGeneration) =>
owner.ExecuteCharacterCreation(
commands => commands.AcknowledgeRejection(expectedGeneration));
}
private sealed class AdapterCharacterCreationObserver(
CurrentGameRuntimeAdapter owner,
IRuntimeCharacterCreationObserver observer)
: IRuntimeCharacterCreationObserver
{
public void OnCharacterCreationChanged(
in RuntimeCharacterCreationDelta delta) =>
owner.ForwardCharacterCreation(observer, in delta);
}
private sealed class AdapterSubscription(
CurrentGameRuntimeAdapter owner,
IDisposable runtimeSubscription) : IDisposable

View file

@ -53,6 +53,12 @@ public sealed record RuntimeOptions(
bool DumpClothing,
int? LegacyStreamRadius,
bool RetailUi,
/// <summary>Campaign CC slice CC4: interim env/test-only seam that opens
/// the character-creation screen once Runtime's chargen view goes
/// active — the real transition is retail's Create Character button
/// (<c>0x100003A0</c>), which stays ghosted until CC7's closing move.
/// See <c>CharacterCreationRuntimeBindings.OpenOnStart</c>.</summary>
bool OpenCharacterCreationOnStart,
string? AcDir,
bool UiProbeDump,
string? UiProbeScript,
@ -146,6 +152,8 @@ public sealed record RuntimeOptions(
// top of the quality preset's radii. Null when unset or invalid.
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
OpenCharacterCreationOnStart:
IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")),
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),

View file

@ -0,0 +1,201 @@
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Heritage page (<c>gmCGHeritagePage</c>, root <c>0x100003d1</c>) — 13
/// race buttons and the composed description text. Decomp anchors:
/// <c>gmCGHeritagePage::InitializePage @ 0x00483a10</c> (button ids),
/// <c>gmCGHeritagePage::ListenToElementMessage @ 0x00483860</c> (the exact
/// button-id -&gt; heritage-id map), <c>gmCGHeritagePage::Update @
/// 0x00483210</c> (description text composition).
/// </summary>
internal sealed class CharacterCreationHeritagePage : IDisposable
{
/// <summary>
/// Button element id -&gt; <c>CharGenState::SetHeritageGroup</c> argument,
/// read verbatim off <c>gmCGHeritagePage::ListenToElementMessage @
/// 0x00483860</c>'s per-case literal (NOT the button element ids'
/// numeric order — e.g. 0x100005e8 maps to heritage 7/Tumerok, not to
/// its own position among the 13 ids).
/// </summary>
private static readonly IReadOnlyDictionary<uint, uint> HeritageByButtonId =
new Dictionary<uint, uint>
{
[0x100003BFu] = (uint)ChargenHeritageGroup.Aluvian,
[0x100003C1u] = (uint)ChargenHeritageGroup.Gharundim,
[0x100003C2u] = (uint)ChargenHeritageGroup.Sho,
// Retail gates this button (Viamontian) behind
// AccountHasThroneOfDestiny (MakeToDWarningDialog otherwise,
// @0x004838e5) — acdream has no account/DLC-ownership signal
// anywhere in ChargenOptions, so this ships without the gate
// (register AD-102, same row as the Town page's Sanamar gate).
[0x100003C3u] = (uint)ChargenHeritageGroup.Viamontian,
[0x10000590u] = (uint)ChargenHeritageGroup.Shadowbound,
[0x100005A9u] = (uint)ChargenHeritageGroup.Gearknight,
[0x100005E8u] = (uint)ChargenHeritageGroup.Tumerok,
[0x100005F1u] = (uint)ChargenHeritageGroup.Lugian,
[0x100005C4u] = (uint)ChargenHeritageGroup.Empyrean,
[0x10000591u] = (uint)ChargenHeritageGroup.Penumbraen,
[0x100005BFu] = (uint)ChargenHeritageGroup.Undead,
[0x100005C7u] = (uint)ChargenHeritageGroup.Olthoi,
[0x100005C8u] = (uint)ChargenHeritageGroup.OlthoiAcid,
};
/// <summary>
/// <c>ID_CharGen_&lt;Abbrev&gt;Text_BonusSkills_Trained</c> per
/// <c>gmCGHeritagePage::Update</c>'s heritage switch (@0x004833e3):
/// Shadowbound and Penumbraen share the SAME string
/// (<c>case 5: case 0xa:</c>, both resolve "ShadText"). Lugian/Olthoi/
/// OlthoiAcid have no matching string in the retail string table (the
/// decompiled switch's cases 8/0xc/0xd resolve to a vtable-slot
/// artifact instead of a string literal, and no
/// "ID_CharGen_Lug*"/"ID_CharGen_Olthoi*" key exists anywhere in the
/// named-retail dump) — those three heritages simply show the shared
/// header text with no per-heritage bonus-skills line, which is
/// retail's own real behavior here, not an acdream gap.
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> BonusSkillsKeyByHeritage =
new Dictionary<uint, string>
{
[(uint)ChargenHeritageGroup.Aluvian] = "ID_CharGen_AluvianText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Gharundim] = "ID_CharGen_GaruText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Sho] = "ID_CharGen_ShoText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Viamontian] = "ID_CharGen_ViaText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Shadowbound] = "ID_CharGen_ShadText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Penumbraen] = "ID_CharGen_ShadText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Gearknight] = "ID_CharGen_GearText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Tumerok] = "ID_CharGen_AunTText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Empyrean] = "ID_CharGen_EmpText_BonusSkills_Trained",
[(uint)ChargenHeritageGroup.Undead] = "ID_CharGen_UndText_BonusSkills_Trained",
};
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly Dictionary<UiButton, uint> _buttons = [];
private readonly UiText? _description;
private bool _disposed;
internal CharacterCreationHeritagePage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings)
{
_bindings = bindings;
foreach ((uint buttonId, uint heritageId) in HeritageByButtonId)
{
if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button)
continue;
_buttons[button] = heritageId;
button.OnClick = () => Select(heritageId);
}
_description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText;
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
foreach ((UiButton button, uint heritageId) in _buttons)
button.Selected = heritageId == snapshot.HeritageId;
if (_description is null)
return;
string composed = ComposeDescription(view, snapshot.HeritageId, _bindings.ResolveText);
_description.LinesProvider = () =>
[new UiText.Line(composed, _description.DefaultColor)];
}
internal void Randomize(RuntimeCharacterCreationSnapshot snapshot)
{
// CharGenState::RandomizeHeritageGroup has no CC3 primitive — the
// nearest faithful approximation available from this page's own
// command surface is a uniform pick over every DAT-installed
// heritage (register AP-212 alongside the Skills/Summary Random
// gaps this same finding covers).
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null || view.Options.HeritagesById.Count == 0)
return;
uint[] ids = [.. view.Options.HeritagesById.Keys];
uint chosen = ids[Random.Shared.Next(ids.Length)];
Select(chosen);
}
private void Select(uint heritageId)
{
if (_disposed)
return;
RuntimeCommandResult result = _bindings.SelectHeritage(heritageId);
if (!result.Accepted)
return;
// CC4 interim default (register AD-101): the Profession/Skills/Town
// pages this slice builds need heritage+gender both selected
// (RuntimeCharacterCreationState.TrySelectTemplate's gate), but
// gender selection lives on the Appearance page (0x100003a7/a8),
// which stays an inert placeholder until CC6b. Auto-select the
// heritage's first available gender so those pages remain usable;
// CC6b's real gender buttons supersede this and the row retires
// then.
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is not null
&& view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
&& heritage.GendersByKey.Count > 0)
{
int genderKey = heritage.GendersByKey.Keys.Min();
_bindings.SelectGender((uint)genderKey);
}
}
/// <summary>
/// Ports <c>gmCGHeritagePage::Update @ 0x00483210</c>'s text
/// composition: the (heritage-independent) starting-skills header +
/// body, the bonus-skills header, then — only once a heritage is
/// selected — that heritage's own bonus-skills line (absent for
/// Lugian/Olthoi/OlthoiAcid; see <see cref="BonusSkillsKeyByHeritage"/>).
/// <paramref name="resolveText"/> is the DAT string lookup
/// (<c>RetailUiRuntime</c>'s <c>DatStringResolver</c> over table
/// <c>0x23000002</c>) threaded through the bindings record; a missing
/// resolver or a missing key degrades to skipping that segment rather
/// than throwing.
/// </summary>
private static string ComposeDescription(
IRuntimeCharacterCreationView view,
uint heritageId,
Func<string, string?>? resolveText)
{
if (resolveText is null)
{
return view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named)
? named.Name
: string.Empty;
}
var parts = new List<string>();
if (resolveText("ID_CharGen_Heritage_StartingSkills_Header") is { } header)
parts.Add(header);
if (resolveText("ID_CharGen_Heritage_StartingSkills") is { } body)
parts.Add(body);
if (resolveText("ID_CharGen_Heritage_BonusSkills_Trained_Header") is { } bonusHeader)
parts.Add(bonusHeader);
if (heritageId != 0
&& BonusSkillsKeyByHeritage.TryGetValue(heritageId, out string? bonusKey)
&& resolveText(bonusKey) is { } bonusBody)
{
parts.Add(bonusBody);
}
return string.Join("\n\n", parts);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (UiButton button in _buttons.Keys)
button.OnClick = null;
_buttons.Clear();
}
}

View file

@ -0,0 +1,264 @@
using System.Globalization;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Profession page (<c>gmCGProfessionPage</c>, root <c>0x100003d2</c>) —
/// seven template buttons and the six attribute sliders. Decomp anchors:
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c> (slider/display
/// element ids), <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>
/// (template-index -&gt; button-id map, cited on <c>ChargenTemplate</c>),
/// <c>gmCGProfessionPage::UpdateAttributeValues @ 0x00482450</c>
/// (avail/health/stamina/mana display sourcing).
/// </summary>
internal sealed class CharacterCreationProfessionPage : IDisposable
{
/// <summary>Template button id -&gt; template index, verbatim off
/// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>'s per-case
/// button-highlight dispatch (also the doc comment on
/// <c>ChargenTemplate</c>): 0 is Custom/Adventurer, and the six preset
/// buttons do NOT sit in template-index order.</summary>
private static readonly IReadOnlyDictionary<uint, uint> TemplateByButtonId =
new Dictionary<uint, uint>
{
[0x100003D9u] = 0u, // Custom / Adventurer
[0x100003DAu] = 1u, // Bow Hunter
[0x100003DFu] = 2u, // Swashbuckler
[0x100003DBu] = 3u, // Life Caster
[0x100003DCu] = 4u, // War Caster (aka War Mage)
[0x100003DDu] = 5u, // Wayfarer
[0x100003DEu] = 6u, // Soldier
};
/// <summary>
/// Attribute id -&gt; slider container element id, verbatim off
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c>:
/// <c>m_tSliderArray[N].pAttribField = GetChildRecursive(this,
/// id)</c> for N=1..6 against ids <c>0x100003e6, e7, e9, e8, ea, eb</c>
/// — note the e8/e9 SWAP (id e9 is slider index 3/Quickness, id e8 is
/// slider index 4/Coordination), matching
/// <see cref="ChargenAttributeId"/>'s own documented 3/4 swap.
/// </summary>
private static readonly IReadOnlyDictionary<ChargenAttributeId, uint> SliderContainerByAttribute =
new Dictionary<ChargenAttributeId, uint>
{
[ChargenAttributeId.Strength] = 0x100003E6u,
[ChargenAttributeId.Endurance] = 0x100003E7u,
[ChargenAttributeId.Coordination] = 0x100003E8u,
[ChargenAttributeId.Quickness] = 0x100003E9u,
[ChargenAttributeId.Focus] = 0x100003EAu,
[ChargenAttributeId.Self] = 0x100003EBu,
};
// Relative (within-container) child ids, same InitializePage loop:
// 0x100002ec = lock UIElement_Button, 0x100002ed = name UIElement_Text
// (left at its authored default — see the ctor comment),
// 0x100002ee = the UIElement_Scrollbar drag control, 0x100002ef = the
// value display. Live-DAT probe (CharacterCreationLiveDatTests):
// 0x100002ef imports as a UiField, not UiText — retail's
// NumberInputFilter (attached to the sibling name field in the decomp,
// @0x00482e36) authors the whole slider row's text sub-elements as
// editable-capable; acdream's factory maps that authored shape to
// UiField. This also lets the player type an exact value directly.
private const uint SliderLockRelativeId = 0x100002ECu;
private const uint SliderControlRelativeId = 0x100002EEu;
private const uint SliderValueRelativeId = 0x100002EFu;
private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value);
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly Dictionary<UiButton, uint> _templateButtons = [];
private readonly Dictionary<ChargenAttributeId, SliderWidgets> _sliders = [];
private readonly UiButton? _availableValue;
private readonly UiButton? _healthValue;
private readonly UiButton? _staminaValue;
private readonly UiButton? _manaValue;
private bool _disposed;
internal CharacterCreationProfessionPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings)
{
_bindings = bindings;
foreach ((uint buttonId, uint templateIndex) in TemplateByButtonId)
{
if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button)
continue;
_templateButtons[button] = templateIndex;
button.OnClick = () => SelectTemplate(templateIndex);
}
foreach ((ChargenAttributeId attribute, uint containerId) in SliderContainerByAttribute)
{
if (UiElement.FindDescendant(pageRoot, containerId) is not { } container)
continue;
UiButton? lockButton = UiElement.FindDescendant(container, SliderLockRelativeId) as UiButton;
UiScrollbar? slider = UiElement.FindDescendant(container, SliderControlRelativeId) as UiScrollbar;
UiField? value = UiElement.FindDescendant(container, SliderValueRelativeId) as UiField;
ChargenAttributeId capturedAttribute = attribute;
if (lockButton is not null)
{
lockButton.OnClick = () => ToggleLock(capturedAttribute);
}
if (slider is not null)
{
slider.Horizontal = true;
slider.ScalarChanged = scalar => SetAttributeFromScalar(capturedAttribute, scalar);
}
if (value is not null)
{
value.Editable = true;
value.CharacterFilter = char.IsAsciiDigit;
value.OnSubmit = text => SetAttributeFromText(capturedAttribute, text);
}
_sliders[attribute] = new SliderWidgets(lockButton, slider, value);
}
// Live-DAT probe (CharacterCreationLiveDatTests): every one of the
// four display containers (0x100003e2..e5) authors as a Button
// whose Type-12 value child (0x100002f1/0x100002f3) is swallowed by
// UiButton.ConsumesDatChildren — the same "consumed child -> use
// the button's own Label" substitution the Skills page's credits
// meter needed (see CharacterCreationSkillsPage's ctor comment).
// Retail's own DynamicCast(0xc) on the CHILD (not the container)
// still stands as ground truth for the container's ROLE; only
// acdream's widget-level addressability differs (register AD-103).
_availableValue = UiElement.FindDescendant(pageRoot, 0x100003E2u) as UiButton;
_healthValue = UiElement.FindDescendant(pageRoot, 0x100003E3u) as UiButton;
_staminaValue = UiElement.FindDescendant(pageRoot, 0x100003E4u) as UiButton;
_manaValue = UiElement.FindDescendant(pageRoot, 0x100003E5u) as UiButton;
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
_ = view;
foreach ((UiButton button, uint templateIndex) in _templateButtons)
button.Selected = templateIndex == snapshot.Template;
foreach ((ChargenAttributeId attribute, SliderWidgets widgets) in _sliders)
{
int value = GetAttribute(snapshot.Attributes, attribute);
float scalar = (value - ChargenAttributeMath.AttributeMin)
/ (float)(ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin);
widgets.Slider?.SetScalarPosition(scalar);
widgets.Value?.SetText(value.ToString(CultureInfo.InvariantCulture));
if (widgets.Lock is { } lockButton)
lockButton.Selected = snapshot.IsAttributeLocked(attribute);
}
SetDisplay(_availableValue, snapshot.RemainingAttributeCredits);
int endurance = snapshot.Attributes.Endurance;
// gmCGProfessionPage::UpdateAttributeValues @ 0x00482450: Health and
// Stamina both read CharGenState::GetAttribute(state, 2)
// (Endurance); Mana reads attribute 6 (Self). The Health call
// alone passes through an FPU divide the decompiler elided
// (_ftol2 @ 0x0048262b with no visible operand) — well-established
// AC vitals convention (Health = floor(Endurance / 2), Stamina =
// Endurance 1:1) is used here; a byte-level x87 trace would be
// needed to pin the exact MSVC rounding mode if this ever needs
// tighter verification.
SetDisplay(_healthValue, endurance / 2);
SetDisplay(_staminaValue, endurance);
SetDisplay(_manaValue, snapshot.Attributes.Self);
}
internal void Randomize(RuntimeCharacterCreationSnapshot snapshot)
{
// CharGenState::RandomizeTemplate has no CC3 primitive — the
// nearest faithful approximation is a uniform pick over this
// heritage's own template list (register AP-212).
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null
|| !view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage)
|| heritage.Templates.Count == 0)
{
return;
}
SelectTemplate((uint)Random.Shared.Next(heritage.Templates.Count));
}
private static int GetAttribute(ChargenAttributeValues values, ChargenAttributeId id) => id switch
{
ChargenAttributeId.Strength => values.Strength,
ChargenAttributeId.Endurance => values.Endurance,
ChargenAttributeId.Quickness => values.Quickness,
ChargenAttributeId.Coordination => values.Coordination,
ChargenAttributeId.Focus => values.Focus,
ChargenAttributeId.Self => values.Self,
_ => 0,
};
private static void SetDisplay(UiButton? display, int value)
{
if (display is null)
return;
display.Label = value.ToString(CultureInfo.InvariantCulture);
}
private void SelectTemplate(uint templateIndex)
{
if (_disposed)
return;
_bindings.SelectTemplate(templateIndex);
}
private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar)
{
if (_disposed)
return;
int value = ChargenAttributeMath.AttributeMin
+ (int)MathF.Round(
scalar * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin),
MidpointRounding.AwayFromZero);
_bindings.SetAttribute(attribute, value);
}
/// <summary>Direct numeric entry via the value field's NumberInputFilter
/// (retail @0x00482e36) — an unparsable/empty submission is a no-op
/// rather than clamping to a guessed default.</summary>
private void SetAttributeFromText(ChargenAttributeId attribute, string text)
{
if (_disposed)
return;
if (int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int value))
_bindings.SetAttribute(attribute, value);
}
private void ToggleLock(ChargenAttributeId attribute)
{
if (_disposed)
return;
RuntimeCharacterCreationSnapshot? snapshot = _bindings.View()?.Snapshot;
bool currentlyLocked = snapshot?.IsAttributeLocked(attribute) ?? false;
_bindings.SetAttributeLock(attribute, !currentlyLocked);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (UiButton button in _templateButtons.Keys)
button.OnClick = null;
_templateButtons.Clear();
foreach (SliderWidgets widgets in _sliders.Values)
{
if (widgets.Lock is { } lockButton)
lockButton.OnClick = null;
if (widgets.Slider is { } slider)
slider.ScalarChanged = null;
if (widgets.Value is { } valueField)
valueField.OnSubmit = null;
}
_sliders.Clear();
}
}

View file

@ -0,0 +1,210 @@
using System.Globalization;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Skills page (<c>gmCGSkillsPage</c>, root <c>0x100003d3</c>) —
/// simplified to one flat listbox rather than retail's four-bucket sorted
/// insertion model (<c>InsertEntrySorted</c>/<c>UpdateSkillEntry</c>,
/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register
/// AP-213). Decomp
/// anchors: <c>gmCGSkillsPage::InitializePage @ 0x00481dd0</c> (listbox
/// <c>0x100003f7</c>, credits meter <c>0x100002f3</c> — imports as button
/// <c>0x100003f9</c>'s own consumed Label, see the ctor comment — info
/// panes <c>0x100003fb</c>/<c>0x100003fc</c>),
/// <c>gmCGSkillsPage::UpdateCreditsMeter
/// @ 0x004808f0</c> (credits display is the raw
/// <c>remainingSkillCredits</c> — no formula). The 16 skill ids uncostable
/// in BOTH the heritage's own list and the global SkillTable (retail's own
/// skills listbox never lists them either — CC1's
/// <c>ChargenTableReaderInstalledDatTests</c>) are filtered out via the
/// same two-tier presence check <c>RuntimeCharacterCreationState</c>'s
/// <c>TryGetSkillCost</c> uses.
/// </summary>
internal sealed class CharacterCreationSkillsPage : IDisposable
{
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiButton? _credits;
private readonly UiText? _infoTitle;
private readonly UiText? _infoText;
private readonly List<UiButton> _rows = [];
private readonly Dictionary<UiButton, uint> _rowSkillIds = [];
private uint _lastHeritageId;
private bool _rowsBuilt;
private bool _disposed;
internal CharacterCreationSkillsPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
Func<uint, uint, UiElement?> templateResolver)
{
_bindings = bindings;
_list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = templateResolver;
// Live-DAT probe (CharacterCreationLiveDatTests): the credits meter
// (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw
// dat CHILD of button 0x100003f9, not as a standalone descendant of
// the page root. UiButton.ConsumesDatChildren swallows it before it
// becomes an addressable widget (the same reason UiMeter's overlay
// text needed an explicit carve-out in LayoutImporter) — the
// faithful substitute is the button's own Label, which is exactly
// the mechanism our factory already uses to surface a consumed
// Type-12 child's text (register AD-103).
_credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton;
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId)
{
RebuildRows(view, snapshot.HeritageId);
_lastHeritageId = snapshot.HeritageId;
_rowsBuilt = true;
}
foreach (UiButton row in _rows)
{
if (!_rowSkillIds.TryGetValue(row, out uint skillId))
continue;
row.Label = FormatSkillLabel(view, snapshot.HeritageId, skillId);
}
if (_credits is { } credits)
credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
}
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
{
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowSkillIds.Clear();
_list?.Flush();
if (_list is null
|| _list.Templates.Count == 0
|| _list.TemplateResolver is null
|| !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
{
return;
}
UiTemplateListEntry template = _list.Templates[0];
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
if (!IsCostable(heritage, view.Options, skillId))
continue;
if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId)
is not UiButton row)
{
continue;
}
_list.AddPrebuiltRow(row);
row.Enabled = true;
row.SuppressSelfToggle = true;
uint capturedSkillId = skillId;
row.OnClick = () => Advance(capturedSkillId);
row.OnDoubleClick = () => Retreat(capturedSkillId);
_rows.Add(row);
_rowSkillIds[row] = skillId;
}
}
/// <summary>Same dictionary-presence gate as
/// <c>RuntimeCharacterCreationState.TryGetSkillCost</c> — heritage list
/// first, global SkillTable fallback.</summary>
private static bool IsCostable(
ChargenHeritageOptions heritage,
ChargenOptions options,
uint skillId) =>
heritage.SkillCostsBySkillId.ContainsKey(skillId)
|| options.GlobalSkillCostsBySkillId.ContainsKey(skillId);
private string FormatSkillLabel(
IRuntimeCharacterCreationView view,
uint heritageId,
uint skillId)
{
string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
(int trainedCost, int specializedCost) = GetCosts(view, heritageId, skillId);
return string.Create(
CultureInfo.InvariantCulture,
$"{name}: {level} (T{trainedCost}/S{specializedCost})");
}
private static (int Trained, int Specialized) GetCosts(
IRuntimeCharacterCreationView view,
uint heritageId,
uint skillId)
{
if (view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
{
if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost))
return (cost.NormalCost, cost.PrimaryCost);
}
if (view.Options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost global))
return (global.NormalCost, global.PrimaryCost);
return (0, 0);
}
/// <summary>OnClick: one step up (Untrained/Inactive -&gt; Trained,
/// Trained -&gt; Specialized). Simplified from retail's separate
/// Increase/Decrease affordances (<c>IncreaseSkillLevel</c>/
/// <c>DecreaseSkillLevel</c>) to one click target per row.</summary>
private void Advance(uint skillId)
{
if (_disposed)
return;
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
?? ChargenSkillAdvancementClass.Inactive;
if (level is ChargenSkillAdvancementClass.Inactive or ChargenSkillAdvancementClass.Untrained)
_bindings.TrainSkill(skillId);
else if (level == ChargenSkillAdvancementClass.Trained)
_bindings.SpecializeSkill(skillId);
}
/// <summary>OnDoubleClick: one step down (Specialized -&gt; Trained,
/// Trained -&gt; Untrained).</summary>
private void Retreat(uint skillId)
{
if (_disposed)
return;
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
?? ChargenSkillAdvancementClass.Inactive;
if (level == ChargenSkillAdvancementClass.Specialized)
_bindings.TrainSkill(skillId);
else if (level == ChargenSkillAdvancementClass.Trained)
_bindings.UntrainSkill(skillId);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowSkillIds.Clear();
_list?.Flush();
if (_list is not null)
_list.TemplateResolver = null;
}
}

View file

@ -0,0 +1,121 @@
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Town page (<c>gmCGTownPage</c>, root <c>0x100003d5</c>) — the four
/// starting-area buttons. Decomp anchors:
/// <c>gmCGTownPage::InitializePage @ 0x0047c6d0</c> (button ids),
/// <c>gmCGTownPage::SetTown @ 0x0047c360</c> (button -&gt;
/// <c>CharGenState::SetStartArea(arg2 - 1)</c> literal index map: Holtburg
/// -&gt; 0, Shoushi -&gt; 1, Yaraq -&gt; 2, Sanamar -&gt; 3),
/// <c>gmCGTownPage::ListenToElementMessage @ 0x0047c480</c> (Sanamar's
/// <c>AccountHasThroneOfDestiny</c> gate — acdream has no account/DLC
/// signal, so it ships without the gate; register AD-102, same row as the
/// Heritage page's Viamontian gate), <c>gmCGTownPage::SetTownString @
/// 0x0047c1f0</c> (composed description text).
/// </summary>
internal sealed class CharacterCreationTownPage : IDisposable
{
/// <summary>Button element id -&gt; the LITERAL <c>startArea</c> index
/// <c>gmCGTownPage::SetTown</c> sends — retail hardcodes these four
/// indices directly rather than looking them up by name, so this port
/// does too.</summary>
private static readonly IReadOnlyDictionary<uint, int> StartAreaByButtonId =
new Dictionary<uint, int>
{
[0x1000040Du] = 0, // Holtburg
[0x1000040Fu] = 1, // Shoushi
[0x1000040Eu] = 2, // Yaraq
[0x1000040Bu] = 3, // Sanamar (ToD-gated in retail; see class doc)
};
private static readonly IReadOnlyDictionary<int, string> TownTextKeyByStartArea =
new Dictionary<int, string>
{
[0] = "ID_CharGen_HoltText",
[1] = "ID_CharGen_ShoushiText",
[2] = "ID_CharGen_YaraqText",
[3] = "ID_CharGen_SanamarText",
};
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly Dictionary<UiButton, int> _buttons = [];
private readonly UiText? _description;
private bool _disposed;
internal CharacterCreationTownPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings)
{
_bindings = bindings;
foreach ((uint buttonId, int startArea) in StartAreaByButtonId)
{
if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button)
continue;
_buttons[button] = startArea;
button.OnClick = () => Select(startArea);
}
_description = UiElement.FindDescendant(pageRoot, 0x10000409u) as UiText;
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
foreach ((UiButton button, int startArea) in _buttons)
button.Selected = startArea == snapshot.StartArea;
if (_description is null)
return;
string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText);
_description.LinesProvider = () =>
[new UiText.Line(composed, _description.DefaultColor)];
}
internal void Randomize(IRuntimeCharacterCreationView view)
{
// CharGenState::SetStartArea(RandInt(hasToD ? 4 : 3)) — acdream
// always treats ToD as owned (see the class doc's AD-102 note), so
// this picks uniformly across all 4 literal indices (register
// AP-212 for the Random approximation itself), clamped to however
// many starter areas the installed DAT actually carries.
int bound = Math.Min(4, view.Options.StarterAreas.Count);
if (bound <= 0)
return;
Select(Random.Shared.Next(bound));
}
private void Select(int startArea)
{
if (_disposed)
return;
_bindings.SelectStartArea(startArea);
}
private static string ComposeDescription(int startArea, Func<string, string?>? resolveText)
{
if (resolveText is null)
return string.Empty;
string? howTo = resolveText("ID_CharGen_TownHowTo");
string? townText = TownTextKeyByStartArea.TryGetValue(startArea, out string? key)
? resolveText(key)
: null;
if (howTo is null && townText is null)
return string.Empty;
return $"{howTo}\n\n{townText}\n";
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (UiButton button in _buttons.Keys)
button.OnClick = null;
_buttons.Clear();
}
}

View file

@ -0,0 +1,650 @@
using System.Numerics;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Bindings the retail character-creation screen (<c>gmCharGenMainUI</c>)
/// needs beyond the borrowed view: generation-capturing command wrappers,
/// mirroring <see cref="CharacterSelectionRuntimeBindings"/>'s shape exactly.
/// Every <c>Func</c> here is a late-bound seam (Campaign CC — see
/// <c>feedback_resolve_deferred_funcs_per_call.md</c>): callers MUST resolve
/// it per-call, never capture the delegate once at mount time.
/// </summary>
/// <param name="OpenOnStart">Campaign CC slice CC4: the retail
/// transition is Create Character (<c>0x100003A0</c>) →
/// <c>QueueUIMode(0x1000000b)</c>, but that button stays ghosted until CC7's
/// closing move. This flag is the interim env/test-only open seam
/// (<c>ACDREAM_OPEN_CHARGEN=1</c> → <see cref="AcDream.App.RuntimeOptions.OpenCharacterCreationOnStart"/>)
/// so the screen can be exercised before the real button is wired.</param>
public sealed record CharacterCreationRuntimeBindings(
Func<IRuntimeCharacterCreationView?> View,
Func<uint, RuntimeCommandResult> SelectHeritage,
Func<uint, RuntimeCommandResult> SelectGender,
Func<uint, RuntimeCommandResult> SelectTemplate,
Func<ChargenAttributeId, int, RuntimeCommandResult> SetAttribute,
Func<ChargenAttributeId, bool, RuntimeCommandResult> SetAttributeLock,
Func<uint, RuntimeCommandResult> TrainSkill,
Func<uint, RuntimeCommandResult> SpecializeSkill,
Func<uint, RuntimeCommandResult> UntrainSkill,
Func<int, RuntimeCommandResult> SelectStartArea,
Func<bool, RuntimeCommandResult> Finish,
Action RequestExit,
/// <summary>DAT string lookup (table <c>0x23000002</c>, the SAME table
/// every other <c>ID_CharGen_*</c>/<c>ID_Character*</c> key resolves
/// through) — used by the Heritage page's composed description text.
/// <see langword="null"/> degrades to the heritage's own DAT
/// <c>Name</c> field instead of the full composed copy.</summary>
Func<string, string?>? ResolveText = null,
bool OpenOnStart = false);
/// <summary>
/// Projects Runtime's borrowed <see cref="IRuntimeCharacterCreationView"/>
/// through retail <c>gmCharGenMainUI</c>'s authored retained layout — the
/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/
/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this
/// slice builds. The Appearance (<c>0x100003d4</c>) and Summary
/// (<c>0x100003d6</c>) page roots are mounted but content-inert — CC6/CC5
/// fill them (register TS-82).
///
/// <para>
/// Decomp anchors: root construction + child resolution
/// <c>gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0</c> (root element
/// <c>0x100003cc</c> from enum <c>0x10000039</c>); page switching
/// <c>gmCharGenMainUI::SetProgressState @ 0x004e7a10</c> (the Olthoi
/// tab-hiding + redirect logic); nav dispatch
/// <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>; exit
/// confirmation <c>gmCharGenMainUI::DoExit @ 0x004e8650</c>; randomize
/// dispatch <c>gmCharGenMainUI::DoRandom @ 0x004e7d70</c>.
/// </para>
/// </summary>
internal sealed class CharacterCreationUiController : IDisposable
{
internal const uint RootEnum = 0x10000039u;
internal const uint RootElementId = 0x100003CCu;
internal const uint ProgressBarElementId = 0x100003CEu;
internal const uint BackElementId = 0x100003C6u;
internal const uint NextElementId = 0x100003C7u;
internal const uint FinishElementId = 0x100003C8u;
internal const uint HelpElementId = 0x100003C9u;
internal const uint ExitElementId = 0x100003CAu;
internal const uint RandomElementId = 0x100003CBu;
internal const uint MasterPageElementId = 0x100003D0u;
internal const uint HeritagePageElementId = 0x100003D1u;
internal const uint ProfessionPageElementId = 0x100003D2u;
internal const uint SkillsPageElementId = 0x100003D3u;
internal const uint AppearancePageElementId = 0x100003D4u;
internal const uint TownPageElementId = 0x100003D5u;
internal const uint SummaryPageElementId = 0x100003D6u;
internal const uint HeritageTabElementId = 0x100003EFu;
internal const uint ProfessionTabElementId = 0x100003F0u;
internal const uint SkillsTabElementId = 0x100003F1u;
internal const uint AppearanceTabElementId = 0x100003F2u;
internal const uint TownTabElementId = 0x100003F3u;
internal const uint SummaryTabElementId = 0x100003F4u;
/// <summary>Retail's <c>gmCharGenMainUI::ECGProgress</c> enum values —
/// used verbatim as the master page's per-page state ids
/// (<c>0x10000025 + (page - 1)</c>) and the tab-hide/redirect math in
/// <see cref="ApplyProgressState"/>.</summary>
internal enum Page
{
Heritage = 1,
Profession = 2,
Skills = 3,
Appearance = 4,
Town = 5,
Summary = 6,
}
internal sealed record DialogStrings(string ExitWarning);
private readonly UiRoot _host;
private readonly ImportedLayout _layout;
private readonly UiElement _progressBar;
private readonly UiButton _back;
private readonly UiButton _next;
private readonly UiButton _finish;
private readonly UiButton _help;
private readonly UiButton _exit;
private readonly UiButton _random;
private readonly UiElement _masterPage;
private readonly UiElement _heritagePageRoot;
private readonly UiElement _professionPageRoot;
private readonly UiElement _skillsPageRoot;
private readonly UiElement _appearancePageRoot;
private readonly UiElement _townPageRoot;
private readonly UiElement _summaryPageRoot;
private readonly UiButton _heritageTab;
private readonly UiButton _professionTab;
private readonly UiButton _skillsTab;
private readonly UiButton _appearanceTab;
private readonly UiButton _townTab;
private readonly UiButton _summaryTab;
private readonly RetailDialogFactory _dialogs;
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly DialogStrings _strings;
private readonly CharacterCreationHeritagePage _heritagePage;
private readonly CharacterCreationProfessionPage _professionPage;
private readonly CharacterCreationSkillsPage _skillsPage;
private readonly CharacterCreationTownPage _townPage;
private Vector2 _authoredCanvas;
private RuntimeGenerationToken _lastGeneration;
private long _lastRevision = long.MinValue;
private Page _currentPage = Page.Heritage;
private bool _active;
private bool _isOpen;
private bool _openOnStartConsumed;
private uint _exitDialogContext;
private bool _suppressDialogCallbacks;
private bool _disposed;
private CharacterCreationUiController(
UiRoot host,
ImportedLayout layout,
UiElement progressBar,
UiButton back,
UiButton next,
UiButton finish,
UiButton help,
UiButton exit,
UiButton random,
UiElement masterPage,
UiElement heritagePageRoot,
UiElement professionPageRoot,
UiElement skillsPageRoot,
UiElement appearancePageRoot,
UiElement townPageRoot,
UiElement summaryPageRoot,
UiButton heritageTab,
UiButton professionTab,
UiButton skillsTab,
UiButton appearanceTab,
UiButton townTab,
UiButton summaryTab,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterCreationRuntimeBindings bindings,
DialogStrings strings)
{
_host = host;
_layout = layout;
_progressBar = progressBar;
_back = back;
_next = next;
_finish = finish;
_help = help;
_exit = exit;
_random = random;
_masterPage = masterPage;
_heritagePageRoot = heritagePageRoot;
_professionPageRoot = professionPageRoot;
_skillsPageRoot = skillsPageRoot;
_appearancePageRoot = appearancePageRoot;
_townPageRoot = townPageRoot;
_summaryPageRoot = summaryPageRoot;
_heritageTab = heritageTab;
_professionTab = professionTab;
_skillsTab = skillsTab;
_appearanceTab = appearanceTab;
_townTab = townTab;
_summaryTab = summaryTab;
_dialogs = dialogs;
_bindings = bindings;
_strings = strings;
Root.Left = 0f;
Root.Top = 0f;
Root.ClickThrough = false;
Root.Visible = false;
// AD-98: the same authored 800x600 fixed-canvas treatment as the
// character-management screen — see that controller's own comment.
// Both screens author the identical extent, so it is safe for both
// controllers to independently (idempotently) push the SAME value
// to the shared UiRoot.FixedCanvasSize; this controller therefore
// never NULLS it back out on close (see Deactivate/Close), leaving
// char-management's own per-tick set as the surviving owner once
// this screen is not the active one.
_authoredCanvas = new Vector2(
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
_heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings);
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450.
_back.OnClick = OnBack;
_next.OnClick = OnNext;
// Finish (0x100003c8) stays ghosted this round: Summary
// (0x100003d6) is CC5's placeholder, and DoFinish's real gate
// sequence lives in RuntimeCharacterCreationState.TryBeginFinish —
// wiring the button here without a Summary page to confirm/collect
// the name would let a click reach the wire with an empty name and
// silently refuse. No OnClick handler; _finish.Enabled stays false
// (see ApplyProgressState).
_finish.OnClick = null;
// Help (0x100003c9) is not handled in gmCharGenMainUI's own
// ListenToElementMessage switch (case 0x100003c9 falls straight
// through to the base UIFramework handler) — retail has no custom
// help action here either; leave it a no-op.
_help.OnClick = null;
_exit.OnClick = OnExit;
_random.OnClick = OnRandom;
_heritageTab.OnClick = () => ApplyProgressState(Page.Heritage);
_professionTab.OnClick = () => ApplyProgressState(Page.Profession);
_skillsTab.OnClick = () => ApplyProgressState(Page.Skills);
_appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance);
_townTab.OnClick = () => ApplyProgressState(Page.Town);
_summaryTab.OnClick = () => ApplyProgressState(Page.Summary);
}
internal UiElement Root => _layout.Root;
internal static CharacterCreationUiController? CreateDetached(
UiRoot host,
ImportedLayout layout,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterCreationRuntimeBindings bindings,
DialogStrings strings)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(dialogs);
ArgumentNullException.ThrowIfNull(bindings);
ArgumentNullException.ThrowIfNull(strings);
if (layout.Root.DatElementId != RootElementId
|| layout.FindElement(ProgressBarElementId) is not { } progressBar
|| layout.FindElement(BackElementId) is not UiButton back
|| layout.FindElement(NextElementId) is not UiButton next
|| layout.FindElement(FinishElementId) is not UiButton finish
|| layout.FindElement(HelpElementId) is not UiButton help
|| layout.FindElement(ExitElementId) is not UiButton exit
|| layout.FindElement(RandomElementId) is not UiButton random
|| layout.FindElement(MasterPageElementId) is not { } masterPage
|| layout.FindElement(HeritagePageElementId) is not { } heritagePageRoot
|| layout.FindElement(ProfessionPageElementId) is not { } professionPageRoot
|| layout.FindElement(SkillsPageElementId) is not { } skillsPageRoot
|| layout.FindElement(AppearancePageElementId) is not { } appearancePageRoot
|| layout.FindElement(TownPageElementId) is not { } townPageRoot
|| layout.FindElement(SummaryPageElementId) is not { } summaryPageRoot
|| layout.FindElement(HeritageTabElementId) is not UiButton heritageTab
|| layout.FindElement(ProfessionTabElementId) is not UiButton professionTab
|| layout.FindElement(SkillsTabElementId) is not UiButton skillsTab
|| layout.FindElement(AppearanceTabElementId) is not UiButton appearanceTab
|| layout.FindElement(TownTabElementId) is not UiButton townTab
|| layout.FindElement(SummaryTabElementId) is not UiButton summaryTab)
{
Console.WriteLine(
"[UI] character creation: the authored root/master-shell contract is incomplete.");
return null;
}
return new CharacterCreationUiController(
host,
layout,
progressBar,
back,
next,
finish,
help,
exit,
random,
masterPage,
heritagePageRoot,
professionPageRoot,
skillsPageRoot,
appearancePageRoot,
townPageRoot,
summaryPageRoot,
heritageTab,
professionTab,
skillsTab,
appearanceTab,
townTab,
summaryTab,
templateResolver,
dialogs,
bindings,
strings);
}
internal void AttachAndTick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (Root.Parent is null)
_host.AddChild(Root);
Tick();
}
internal void Tick()
{
if (_disposed)
return;
IRuntimeCharacterCreationView? view = _bindings.View();
RuntimeCharacterCreationSnapshot snapshot = view?.Snapshot ?? default;
if (view is null || !snapshot.IsActive)
{
Deactivate();
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
return;
}
if (!_active)
{
_active = true;
// CC4 interim open seam (ACDREAM_OPEN_CHARGEN=1) — the real
// Create-button transition is CC7's. Fires once per mount.
if (_bindings.OpenOnStart && !_openOnStartConsumed)
{
_openOnStartConsumed = true;
Open();
}
}
if (_isOpen)
{
Root.Visible = true;
_host.FixedCanvasSize = _authoredCanvas;
_host.BringToFront(Root);
}
else
{
Root.Visible = false;
}
if (_lastGeneration != snapshot.Generation
|| _lastRevision != snapshot.Revision)
{
_heritagePage.Refresh(view, snapshot);
_professionPage.Refresh(view, snapshot);
_skillsPage.Refresh(view, snapshot);
_townPage.Refresh(view, snapshot);
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
}
ReconcileDialogs(snapshot);
}
/// <summary>Opens the screen at retail's authored default page
/// (<c>gmCharGenMainUI::gmCharGenMainUI</c>'s trailing
/// <c>SetProgressState(this, ECG_HERTAGE)</c>).</summary>
internal void Open()
{
if (_disposed)
return;
_isOpen = true;
ApplyProgressState(Page.Heritage);
}
private void Close()
{
_isOpen = false;
Root.Visible = false;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
CloseAllDialogs(suppressCallbacks: true);
}
finally
{
_back.OnClick = null;
_next.OnClick = null;
_finish.OnClick = null;
_help.OnClick = null;
_exit.OnClick = null;
_random.OnClick = null;
_heritageTab.OnClick = null;
_professionTab.OnClick = null;
_skillsTab.OnClick = null;
_appearanceTab.OnClick = null;
_townTab.OnClick = null;
_summaryTab.OnClick = null;
_heritagePage.Dispose();
_professionPage.Dispose();
_skillsPage.Dispose();
_townPage.Dispose();
_host.RemoveChild(Root);
}
}
// ── Nav dispatch (gmCharGenMainUI::ListenToElementMessage @ 0x004e9450) ──
private void OnBack()
{
if (_disposed)
return;
if (_currentPage <= Page.Heritage)
{
OnExit();
return;
}
ApplyProgressState(_currentPage - 1);
}
private void OnNext()
{
if (_disposed)
return;
if (_currentPage < Page.Summary)
ApplyProgressState(_currentPage + 1);
}
private void OnExit()
{
if (_disposed)
return;
// gmCharGenMainUI::DoExit @ 0x004e8650's own guard: a second Exit
// click while the confirmation is already open is a no-op.
if (_exitDialogContext != 0u)
return;
_exitDialogContext = _dialogs.MakeConfirmation(
_strings.ExitWarning,
data =>
{
_exitDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
// RecvNotice_CloseDialog @ 0x004e9780's exit-context branch:
// confirm -> QueueUIMode(0x1000000a) (leave chargen). Our
// equivalent is closing this screen; whatever mounted the
// character-management screen already keeps re-drawing it
// underneath (this screen only BringToFront's itself while
// open — see Tick).
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
{
Close();
_bindings.RequestExit();
}
});
}
private void OnRandom()
{
if (_disposed)
return;
// gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town
// are ported below; Skills' CharGenState::RandomizeSkills and the
// Summary randomize-warning dialog have no CC3 primitive/page yet
// this round — register AP-212 covers both gaps, and _random.Enabled
// already keeps the control ghosted on those pages (ApplyProgressState).
IRuntimeCharacterCreationView? view = _bindings.View();
if (view is null)
return;
RuntimeCharacterCreationSnapshot snapshot = view.Snapshot;
switch (_currentPage)
{
case Page.Heritage:
_heritagePage.Randomize(snapshot);
break;
case Page.Profession:
_professionPage.Randomize(snapshot);
break;
case Page.Town:
_townPage.Randomize(view);
break;
}
}
// ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ────
private void ApplyProgressState(Page target)
{
_heritagePageRoot.Visible = false;
_professionPageRoot.Visible = false;
_skillsPageRoot.Visible = false;
_appearancePageRoot.Visible = false;
_townPageRoot.Visible = false;
_summaryPageRoot.Visible = false;
_next.Visible = true;
_finish.Visible = false;
Page previous = _currentPage;
_currentPage = target;
_heritageTab.Selected = false;
_professionTab.Selected = false;
_skillsTab.Selected = false;
_appearanceTab.Selected = false;
_townTab.Selected = false;
_summaryTab.Selected = false;
uint heritageId = _bindings.View()?.Snapshot.HeritageId ?? 0u;
bool isOlthoi = heritageId == (uint)ChargenHeritageGroup.Olthoi
|| heritageId == (uint)ChargenHeritageGroup.OlthoiAcid;
if (isOlthoi)
{
_professionTab.Visible = false;
_skillsTab.Visible = false;
_townTab.Visible = false;
if (_currentPage < previous)
{
if (_currentPage is Page.Profession or Page.Skills)
_currentPage = Page.Heritage;
else if (_currentPage == Page.Town)
_currentPage = Page.Appearance;
}
else
{
if (_currentPage is Page.Profession or Page.Skills)
_currentPage = Page.Appearance;
else if (_currentPage == Page.Town)
_currentPage = Page.Summary;
}
}
else
{
_professionTab.Visible = true;
_skillsTab.Visible = true;
_townTab.Visible = true;
}
SetMasterPageState(0x10000025u + (uint)_currentPage - 1u);
switch (_currentPage)
{
case Page.Heritage:
_heritagePageRoot.Visible = true;
_heritageTab.Selected = true;
break;
case Page.Profession:
_professionPageRoot.Visible = true;
_professionTab.Selected = true;
break;
case Page.Skills:
_skillsPageRoot.Visible = true;
_skillsTab.Selected = true;
break;
case Page.Appearance:
_appearancePageRoot.Visible = true;
_appearanceTab.Selected = true;
break;
case Page.Town:
_townPageRoot.Visible = true;
_townTab.Selected = true;
break;
case Page.Summary:
_summaryPageRoot.Visible = true;
_summaryTab.Selected = true;
_next.Visible = false;
_finish.Visible = true;
break;
}
// Random (0x100003cb): retail refuses on Skills (no
// RandomizeSkills primitive ported — AP-212) and on Summary
// (MakeRandomizeWarningDialog is CC5's); Appearance is this round's
// placeholder.
_random.Enabled = _currentPage
is not (Page.Skills or Page.Appearance or Page.Summary);
// Finish stays ghosted regardless of page — Summary is a
// placeholder this round (see the ctor comment on _finish.OnClick).
_finish.Enabled = false;
_lastRevision = long.MinValue;
Tick();
}
private void SetMasterPageState(uint stateId)
{
if (_masterPage is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
{
// Local-refusal / rejection surfacing is CC5's Summary-page job
// (the Finish gate only fires from that page). This round only
// needs the exit-confirmation dialog reconciled against disposal.
_ = snapshot;
}
private void Deactivate()
{
if (_active)
{
_active = false;
_isOpen = false;
_openOnStartConsumed = false;
Root.Visible = false;
}
CloseAllDialogs(suppressCallbacks: true);
}
private void CloseAllDialogs(bool suppressCallbacks)
{
bool previous = _suppressDialogCallbacks;
_suppressDialogCallbacks |= suppressCallbacks;
try
{
if (_exitDialogContext != 0u)
{
uint closing = _exitDialogContext;
_exitDialogContext = 0u;
_dialogs.CloseDialog(closing);
}
}
finally
{
_suppressDialogCallbacks = previous;
}
}
}

View file

@ -0,0 +1,99 @@
namespace AcDream.App.UI.Layout;
internal sealed record CharacterCreationUiMountResources(
uint LayoutId,
ImportedLayout Layout,
Func<uint, uint, UiElement?> TemplateResolver,
CharacterCreationUiController.DialogStrings Strings);
/// <summary>
/// Retryable, idempotent composition edge for the character-creation screen —
/// clone of <see cref="CharacterManagementUiMountCoordinator"/>'s recipe. DATs
/// can become readable after the graphical runtime starts, so an unavailable
/// dialog catalog, root, or string must not permanently suppress the screen.
/// </summary>
internal sealed class CharacterCreationUiMountCoordinator : IDisposable
{
private readonly UiRoot _host;
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterCreationUiMountResources?> _loadResources;
private bool _disposed;
public CharacterCreationUiMountCoordinator(
UiRoot host,
CharacterCreationRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterCreationUiMountResources?> loadResources)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_ensureDialogs = ensureDialogs
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
}
public CharacterCreationUiController? Controller { get; private set; }
public void Tick()
{
if (_disposed || Controller is not null)
return;
try
{
RetailDialogFactory? dialogs = _ensureDialogs();
if (dialogs is null)
return;
CharacterCreationUiMountResources? resources = _loadResources();
if (resources is null)
return;
CharacterCreationUiController? candidate =
CharacterCreationUiController.CreateDetached(
_host,
resources.Layout,
resources.TemplateResolver,
dialogs,
_bindings,
resources.Strings);
if (candidate is null)
return;
Controller = candidate;
candidate.AttachAndTick();
Console.WriteLine(
$"[UI] retail character creation from enum table 5 "
+ $"(0x10000039 -> 0x{resources.LayoutId:X8}, root 0x100003CC).");
}
catch (Exception error)
{
CharacterCreationUiController? partial = Controller;
Controller = null;
try
{
partial?.Dispose();
}
catch (Exception cleanupError)
{
Console.WriteLine(
"[UI] character creation partial-mount cleanup failed: "
+ cleanupError.Message);
}
Console.WriteLine(
"[UI] character creation mount will retry after resource "
+ $"recovery: {error.Message}");
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Controller?.Dispose();
Controller = null;
}
}

View file

@ -1716,7 +1716,10 @@ public static class ItemAppraisalTextFormatter
};
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c>.</summary>
private static string SkillName(int skill) => skill switch
/// <summary>Retail skill-id -&gt; display-name table. Made <c>internal</c>
/// (Campaign CC slice CC4) so the chargen Skills page can reuse the
/// same names instead of duplicating this table.</summary>
internal static string SkillName(int skill) => skill switch
{
1 => "Axe",
2 => "Bow",

View file

@ -427,7 +427,9 @@ public sealed record RetailUiRuntimeBindings(
RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe,
KeyboardRuntimeBindings? Keyboard = null,
CharacterSelectionRuntimeBindings? CharacterSelection = null);
CharacterSelectionRuntimeBindings? CharacterSelection = null,
// Campaign CC slice CC4: sibling of CharacterSelection above.
CharacterCreationRuntimeBindings? CharacterCreation = null);
/// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -450,6 +452,7 @@ public sealed class RetailUiRuntime : IDisposable
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private IDisposable? _characterSheetSubscription;
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
@ -518,6 +521,8 @@ public sealed class RetailUiRuntime : IDisposable
MountItemCooldowns();
ConfigureCharacterManagement();
_characterManagementMount?.Tick();
ConfigureCharacterCreation();
_characterCreationMount?.Tick();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@ -614,6 +619,8 @@ public sealed class RetailUiRuntime : IDisposable
public SocialPanelController? SocialPanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CharacterCreationUiController? CharacterCreationController =>
_characterCreationMount?.Controller;
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -661,6 +668,8 @@ public sealed class RetailUiRuntime : IDisposable
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
_characterCreationMount?.Tick();
CharacterCreationController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
@ -3875,6 +3884,87 @@ public sealed class RetailUiRuntime : IDisposable
private static string NormalizeRetailNewlines(string value) =>
value.Replace("\\n", "\n", StringComparison.Ordinal);
private void ConfigureCharacterCreation()
{
CharacterCreationRuntimeBindings? bindings = _bindings.CharacterCreation;
if (bindings is null || _characterCreationMount is not null)
return;
_characterCreationMount = new CharacterCreationUiMountCoordinator(
Host.Root,
bindings,
EnsureDialogFactory,
LoadCharacterCreationResources);
}
private CharacterCreationUiMountResources? LoadCharacterCreationResources()
{
const uint stringTableId = 0x23000002u;
uint layoutId;
ImportedLayout? layout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
// gmCharGenMainUI's framework registration passes enum
// 0x10000039 and category/table 5, then selects root 0x100003CC.
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
CharacterCreationUiController.RootEnum,
5u);
layout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CharacterCreationUiController.RootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine(
"[UI] character creation: enum-table-5 root could not be imported.");
return null;
}
string? exitWarning;
lock (_bindings.Assets.DatLock)
{
exitWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_ExitWarning");
}
if (exitWarning is null)
{
Console.WriteLine(
"[UI] character creation: required retail strings are unavailable.");
return null;
}
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
templateLayoutId,
templateElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont)?.Root;
}
}
return new CharacterCreationUiMountResources(
layoutId,
layout,
ResolveTemplate,
new CharacterCreationUiController.DialogStrings(exitWarning));
}
private void MountItemCooldowns()
{
ItemCooldownAssets? assets;
@ -3921,6 +4011,7 @@ public sealed class RetailUiRuntime : IDisposable
() =>
{
_characterManagementMount?.Dispose();
_characterCreationMount?.Dispose();
_gameplayConfirmationController?.Dispose();
},
() => DialogFactory?.Dispose(),

View file

@ -409,7 +409,18 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Id,
selection.CharacterId,
selection.CharacterName),
loginCommands));
loginCommands,
// Campaign CC slice CC4: same status-parity wiring as
// the graphical host (LiveSessionRuntimeFactory.Create).
CharacterCreated: identity => statusWriter.CharacterCreated(
descriptor.Id,
identity.Guid,
identity.Name),
CreationFailed: rejection => statusWriter.CreationFailed(
descriptor.Id,
rejection.RawCode,
rejection.Reason,
rejection.AttemptedName)));
Runtime = runtime;
Commands = commands;

View file

@ -533,6 +533,8 @@ public sealed class GameRuntime
public IRuntimeChatView Chat => CommunicationOwner.View;
public IRuntimeCharacterSelectionView CharacterSelection =>
Session.CharacterSelection;
public IRuntimeCharacterCreationView CharacterCreation =>
Session.CharacterCreation;
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;

View file

@ -280,6 +280,12 @@ public interface IGameRuntimeView
throw new NotSupportedException(
"This runtime view does not project character selection.");
/// <summary>Campaign CC slice CC4: mirrors <see cref="CharacterSelection"/>'s
/// default-throw shape.</summary>
IRuntimeCharacterCreationView CharacterCreation =>
throw new NotSupportedException(
"This runtime view does not project character creation.");
IRuntimeFellowshipView Fellowship { get; }
IRuntimeAllegianceView Allegiance { get; }

View file

@ -41,7 +41,16 @@ public sealed record LiveSessionHostBindings(
/// fan-out, this exists so a status writer can emit the
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
Action<LiveSessionCharacterSelection> CharacterEntered,
LoginCommandSequence? LoginCommands = null);
LoginCommandSequence? LoginCommands = null,
/// <summary>Campaign CC slice CC4: forwards
/// <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/> —
/// hosts wire this to <c>SessionStatusWriter.CharacterCreated</c>.
/// Default no-op.</summary>
Action<RuntimeCharacterCreationIdentity>? CharacterCreated = null,
/// <summary>Campaign CC slice CC4: forwards
/// <see cref="ILiveSessionLifecycleHost.ApplyCreationFailed"/> — hosts
/// wire this to <c>SessionStatusWriter.CreationFailed</c>.</summary>
Action<RuntimeCharacterCreationRejection>? CreationFailed = null);
/// <summary>
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
@ -136,7 +145,9 @@ public sealed class LiveSessionHost
Connected: bindings.Connected,
Roster: bindings.Roster,
Selected: ApplySelection,
Entered: ApplyEnteredWorld));
Entered: ApplyEnteredWorld,
CharacterCreated: bindings.CharacterCreated,
CreationFailed: bindings.CreationFailed));
}
public WorldSession? CurrentSession => _controller.CurrentSession;

View file

@ -9,7 +9,15 @@ public sealed record LiveSessionLifecycleBindings(
Action Connected,
Action<LiveSessionRosterReport> Roster,
Action<LiveSessionCharacterSelection> Selected,
Action<LiveSessionCharacterSelection> Entered);
Action<LiveSessionCharacterSelection> Entered,
/// <summary>Campaign CC slice CC4: forwards
/// <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/>. Default
/// no-op preserves every existing positional/named construction site
/// that predates this field.</summary>
Action<RuntimeCharacterCreationIdentity>? CharacterCreated = null,
/// <summary>Campaign CC slice CC4: forwards
/// <see cref="ILiveSessionLifecycleHost.ApplyCreationFailed"/>.</summary>
Action<RuntimeCharacterCreationRejection>? CreationFailed = null);
/// <summary>
/// Focused adapter between the session lifetime owner and its composition
@ -62,6 +70,12 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
_bindings.Entered(selection);
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) =>
_bindings.CharacterCreated?.Invoke(identity);
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) =>
_bindings.CreationFailed?.Invoke(rejection);
public void DetachSession(WorldSession session)
{
if (!ReferenceEquals(_boundSession, session))

View file

@ -281,7 +281,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
private readonly object _gate = new();
private readonly CharacterCreationEventStream _events = new();
private readonly ViewProjection _view;
private readonly ChargenOptions _options;
private ChargenOptions _options;
private readonly Random _random;
private RuntimeGenerationToken _generation;
private bool _active;
@ -321,6 +321,39 @@ public sealed class RuntimeCharacterCreationState : IDisposable
public ChargenOptions Options => _options;
/// <summary>
/// Campaign CC slice CC4: installs the real chargen options loaded from
/// the installed DAT (<c>AcDream.Content.CharGen.ChargenTableReader.Load</c>)
/// once the content host's DAT collection opens, mirroring the
/// established "install immutable DAT metadata after construction"
/// pattern (<c>RuntimeCharacterState.InstallSpellMetadata</c> ->
/// <c>Spellbook.InstallMetadata</c>). GameWindow constructs the
/// <see cref="AcDream.Runtime.GameRuntime"/> (and therefore this state,
/// defaulted to <see cref="ChargenOptions.Empty"/>) before portal.dat is
/// open; <c>ContentEffectsAudioCompositionPhase.Compose</c> calls this
/// once DATs are published, always well before
/// <see cref="Begin"/> — no character-selection/creation session can be
/// active yet at that point in the composition sequence, so there is no
/// concurrent read to race. Throws if called while a session is already
/// active — a second install after chargen has started reading the
/// first one would be a genuine caller bug, not a case to silently
/// tolerate.
/// </summary>
public void InstallOptions(ChargenOptions options)
{
ArgumentNullException.ThrowIfNull(options);
lock (_gate)
{
ThrowIfDisposed();
if (_active)
{
throw new InvalidOperationException(
"Chargen options cannot be installed while a character-creation session is active.");
}
_options = options;
}
}
public RuntimeCharacterCreationSnapshot Snapshot
{
get

View file

@ -10,6 +10,7 @@ using AcDream.App.Spells;
using AcDream.Content;
using AcDream.Content.Vfx;
using AcDream.Core.Audio;
using AcDream.Core.CharGen;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
@ -17,6 +18,7 @@ using AcDream.Core.Spells;
using AcDream.Core.Vfx;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using DatReaderWriter.DBObjs;
using Silk.NET.Input;
using Silk.NET.OpenAL;
@ -403,6 +405,11 @@ public sealed class ContentEffectsAudioCompositionTests
RuntimeCharacterState character,
MagicCatalog catalog) { }
public int GetSpellCount(MagicCatalog catalog) => 0;
public ChargenOptions LoadChargenOptions(IDatReaderWriter dats) =>
ChargenOptions.Empty;
public void InstallChargenOptions(
LiveSessionController session,
ChargenOptions options) { }
public IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,

View file

@ -0,0 +1,334 @@
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Installed-retail-DAT acceptance gate for Campaign CC slice CC4. Opt in
/// with <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can
/// override the ordinary Documents/Asheron's Call location. Mirrors
/// <see cref="CharacterManagementLiveDatTests"/>'s pattern: sweeps the
/// authored master-shell and page ids the campaign plan and CC4's own
/// decomp research cite, pinning them against the real installed layout.
/// </summary>
public sealed class CharacterCreationLiveDatTests
{
private static string DatDirectory =>
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
[InstalledDatFact]
public void EnumTable5_ResolvesTheMasterShellRootAndChildren()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
Assert.NotEqual(0u, layoutId);
Console.WriteLine(
$"[CC4-DAT] category=5 enum=0x10000039 -> DID=0x{layoutId:X8}");
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
Assert.Equal(
CharacterCreationUiController.RootElementId,
screen.Root.DatElementId);
Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProgressBarElementId));
AssertButton(screen, CharacterCreationUiController.BackElementId);
AssertButton(screen, CharacterCreationUiController.NextElementId);
AssertButton(screen, CharacterCreationUiController.FinishElementId);
AssertButton(screen, CharacterCreationUiController.HelpElementId);
AssertButton(screen, CharacterCreationUiController.ExitElementId);
AssertButton(screen, CharacterCreationUiController.RandomElementId);
Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.MasterPageElementId));
foreach (uint pageId in new[]
{
CharacterCreationUiController.HeritagePageElementId,
CharacterCreationUiController.ProfessionPageElementId,
CharacterCreationUiController.SkillsPageElementId,
CharacterCreationUiController.AppearancePageElementId,
CharacterCreationUiController.TownPageElementId,
CharacterCreationUiController.SummaryPageElementId,
})
{
Assert.IsAssignableFrom<UiElement>(screen.FindElement(pageId));
}
AssertButton(screen, CharacterCreationUiController.HeritageTabElementId);
AssertButton(screen, CharacterCreationUiController.ProfessionTabElementId);
AssertButton(screen, CharacterCreationUiController.SkillsTabElementId);
AssertButton(screen, CharacterCreationUiController.AppearanceTabElementId);
AssertButton(screen, CharacterCreationUiController.TownTabElementId);
AssertButton(screen, CharacterCreationUiController.SummaryTabElementId);
}
[InstalledDatFact]
public void MountsThroughTheControllerAgainstLiveResources()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
var host = new UiRoot();
var dialogs = MakeDialogFactory(dats, host);
var bindings = new CharacterCreationRuntimeBindings(
() => null,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { });
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) =>
LayoutImporter.Import(
dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root;
CharacterCreationUiController? controller =
CharacterCreationUiController.CreateDetached(
host, screen, ResolveTemplate, dialogs, bindings,
new CharacterCreationUiController.DialogStrings("Are you sure?"));
Assert.NotNull(controller);
controller!.AttachAndTick();
controller.Dispose();
dialogs.Dispose();
}
/// <summary>13 heritage buttons, the description text, all present as
/// authored (Heritage page — <c>gmCGHeritagePage::InitializePage @
/// 0x00483a10</c>).</summary>
[InstalledDatFact]
public void HeritagePage_HasAllThirteenRaceButtonsAndDescriptionText()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
uint[] heritageButtonIds =
[
0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u,
0x10000590u, 0x100005A9u, 0x100005E8u, 0x100005F1u,
0x100005C4u, 0x10000591u, 0x100005BFu, 0x100005C7u,
0x100005C8u,
];
foreach (uint buttonId in heritageButtonIds)
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(heritageRoot, buttonId));
}
Assert.IsType<UiText>(
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
}
/// <summary>Seven template buttons, six attribute sliders (each with a
/// lock button + scrollbar + value text), and the four derived
/// displays (Profession page —
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c>).</summary>
[InstalledDatFact]
public void ProfessionPage_HasTemplateButtonsSlidersAndDisplays()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement professionRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProfessionPageElementId));
uint[] templateButtonIds =
[
0x100003D9u, 0x100003DAu, 0x100003DBu,
0x100003DCu, 0x100003DDu, 0x100003DEu, 0x100003DFu,
];
foreach (uint buttonId in templateButtonIds)
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(professionRoot, buttonId));
}
uint[] sliderContainerIds =
[
0x100003E6u, 0x100003E7u, 0x100003E8u,
0x100003E9u, 0x100003EAu, 0x100003EBu,
];
foreach (uint containerId in sliderContainerIds)
{
UiElement container = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(professionRoot, containerId));
Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(container, 0x100002EEu));
// The value display authors as an editable Type-12 (retail's
// NumberInputFilter) — DatWidgetFactory maps that to UiField,
// not UiText. See CharacterCreationProfessionPage's ctor comment.
Assert.IsType<UiField>(
UiElement.FindDescendant(container, 0x100002EFu));
}
// Avail/health/stamina/mana each author as a Button whose Type-12
// value child is swallowed by UiButton.ConsumesDatChildren — the
// same substitution the Skills page's credits meter needed. See
// CharacterCreationProfessionPage's ctor comment.
foreach (uint containerId in new[]
{ 0x100003E2u, 0x100003E3u, 0x100003E4u, 0x100003E5u })
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(professionRoot, containerId));
}
}
/// <summary>Skills listbox, credits meter, info panes (Skills page —
/// <c>gmCGSkillsPage::InitializePage @ 0x00481dd0</c>).</summary>
[InstalledDatFact]
public void SkillsPage_HasListboxCreditsAndInfoPanes()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement skillsRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.SkillsPageElementId));
Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(skillsRoot, 0x100003F7u));
// The credits meter (decomp id 0x100002f3) authors as a raw dat
// child of button 0x100003f9; UiButton.ConsumesDatChildren swallows
// it before it becomes an addressable widget, so the faithful
// acdream substitute is the button's own Label — see
// CharacterCreationSkillsPage's ctor comment.
Assert.IsType<UiButton>(
UiElement.FindDescendant(skillsRoot, 0x100003F9u));
Assert.IsType<UiText>(
UiElement.FindDescendant(skillsRoot, 0x100003FBu));
Assert.IsType<UiText>(
UiElement.FindDescendant(skillsRoot, 0x100003FCu));
}
/// <summary>Four town buttons + description text (Town page —
/// <c>gmCGTownPage::InitializePage @ 0x0047c6d0</c>).</summary>
[InstalledDatFact]
public void TownPage_HasFourStarterAreaButtons()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats,
CharacterCreationUiController.RootEnum,
5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement townRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.TownPageElementId));
foreach (uint buttonId in new[] { 0x1000040Bu, 0x1000040Du, 0x1000040Eu, 0x1000040Fu })
{
Assert.IsType<UiButton>(
UiElement.FindDescendant(townRoot, buttonId));
}
Assert.IsType<UiText>(
UiElement.FindDescendant(townRoot, 0x10000409u));
}
/// <summary>The exit-warning + all per-heritage/per-town DAT string
/// keys this slice cites actually resolve in the installed table.
/// </summary>
[InstalledDatFact]
public void RequiredChargenStringsResolve()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var strings = new DatStringResolver(dats);
const uint table = 0x23000002u;
string[] keys =
[
"ID_CharGen_ExitWarning",
"ID_CharGen_Heritage_StartingSkills_Header",
"ID_CharGen_Heritage_StartingSkills",
"ID_CharGen_Heritage_BonusSkills_Trained_Header",
"ID_CharGen_AluvianText_BonusSkills_Trained",
"ID_CharGen_GaruText_BonusSkills_Trained",
"ID_CharGen_ShoText_BonusSkills_Trained",
"ID_CharGen_ViaText_BonusSkills_Trained",
"ID_CharGen_ShadText_BonusSkills_Trained",
"ID_CharGen_GearText_BonusSkills_Trained",
"ID_CharGen_AunTText_BonusSkills_Trained",
"ID_CharGen_EmpText_BonusSkills_Trained",
"ID_CharGen_UndText_BonusSkills_Trained",
"ID_CharGen_TownHowTo",
"ID_CharGen_HoltText",
"ID_CharGen_ShoushiText",
"ID_CharGen_YaraqText",
"ID_CharGen_SanamarText",
];
foreach (string key in keys)
{
string? resolved = strings.Resolve(table, DatStringResolver.ComputeHash(key));
Assert.True(resolved is not null, $"missing string: {key}");
}
}
private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host)
{
uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u);
ImportedLayout? CreateLayout(RetailDialogType type)
{
uint rootElementId = RetailDialogFactory.RootElementId(type);
return rootElementId == 0u
? null
: LayoutImporter.Import(
dats, dialogDid, rootElementId, _ => (0u, 0, 0), null);
}
return new RetailDialogFactory(host, CreateLayout);
}
private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId));
private static ImportedLayout BuildSelected(
IDatReaderWriter dats,
uint layoutDid,
uint rootId)
{
ElementInfo info = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, layoutDid, rootId));
return LayoutImporter.Build(
info,
_ => (0u, 0, 0),
null,
null,
new DatStringResolver(dats).Resolve);
}
}

View file

@ -0,0 +1,817 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC slice CC4 — controller binding tests for the character-
/// creation master shell + Heritage/Profession/Skills/Town pages, using a
/// hand-built layout fixture (no installed DAT — see
/// <see cref="CharacterCreationLiveDatTests"/> for the live-DAT id/type
/// sweep this pairs with). Mirrors <c>CharacterManagementUiControllerTests</c>'
/// fixture pattern.
/// </summary>
public sealed class CharacterCreationUiControllerTests
{
private const uint AluvianId = 1u;
private const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi;
private const uint GenderKey = 1u;
private const uint SkillTrainOnly = 1u;
private const uint SkillSpecializable = 2u;
[Fact]
public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage()
{
using var environment = new EnvironmentHarness();
Assert.False(environment.Controller.Root.Visible);
environment.Controller.Open();
Assert.True(environment.Controller.Root.Visible);
Assert.Equal(800f, environment.Controller.Root.Width);
Assert.Equal(600f, environment.Controller.Root.Height);
Assert.True(environment.Page(
CharacterCreationUiController.HeritagePageElementId).Visible);
Assert.False(environment.Page(
CharacterCreationUiController.ProfessionPageElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.HeritageTabElementId).Selected);
}
[Fact]
public void TabClick_SwitchesToTheClickedPage_FreeOfValidation()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
// Free navigation: jumping straight to Town from Heritage with
// nothing selected must still work (gmCharGenMainUI's tab dispatch
// is not gated — see ApplyProgressState's doc).
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.TownPageElementId).Visible);
Assert.False(environment.Page(
CharacterCreationUiController.HeritagePageElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Selected);
}
[Fact]
public void Next_AdvancesOnePageAtATime_AndBackReturns()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.ProfessionPageElementId).Visible);
environment.Button(CharacterCreationUiController.BackElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.HeritagePageElementId).Visible);
}
[Fact]
public void Next_AtSummary_IsANoOp()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.SummaryTabElementId)
.OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.SummaryPageElementId).Visible);
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.SummaryPageElementId).Visible);
}
[Fact]
public void Finish_StaysGhosted_NoOnClickHandler()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId);
Assert.Null(finish.OnClick);
Assert.False(finish.Enabled);
}
[Fact]
public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
UiButton random = environment.Button(CharacterCreationUiController.RandomElementId);
Assert.True(random.Enabled);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
Assert.False(random.Enabled);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
Assert.False(random.Enabled);
environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!();
Assert.False(random.Enabled);
environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!();
Assert.True(random.Enabled);
}
/// <summary>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's
/// element 0x100003c6 case: at Heritage (the first page), Back opens
/// the exit confirmation instead of moving pages.</summary>
[Fact]
public void Back_AtHeritage_OpensExitConfirmation()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.BackElementId).OnClick!();
Assert.True(environment.Dialogs.IsOpen);
}
[Fact]
public void Exit_Confirm_ClosesTheScreenAndCallsRequestExit()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
Assert.True(environment.Dialogs.IsOpen);
environment.ConfirmActiveDialog(confirmed: true);
Assert.False(environment.Controller.Root.Visible);
Assert.Equal(1, environment.Runtime.RequestExitCalls);
}
[Fact]
public void Exit_Cancel_LeavesTheScreenOpen()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: false);
Assert.True(environment.Controller.Root.Visible);
Assert.Equal(0, environment.Runtime.RequestExitCalls);
}
/// <summary>gmCGHeritagePage::ListenToElementMessage @ 0x00483860's
/// per-button SetHeritageGroup literal, plus CC4's interim
/// auto-gender-select seam (register AD-100).</summary>
[Fact]
public void HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(0x100003BFu).OnClick!(); // Aluvian
Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage);
Assert.Equal(GenderKey, environment.Runtime.LastSelectedGender);
}
/// <summary>gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi
/// branch: Profession/Skills/Town tabs hide, and paging past the
/// hidden range redirects to Appearance/Summary.</summary>
[Fact]
public void OlthoiHeritage_HidesProfessionSkillsAndTownTabs()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(OlthoiId);
environment.TabButton(CharacterCreationUiController.HeritageTabElementId)
.OnClick!();
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
// Next from Heritage would normally land on Profession; for an
// Olthoi heritage it must redirect straight to Appearance.
environment.Button(CharacterCreationUiController.NextElementId).OnClick!();
Assert.True(environment.Page(
CharacterCreationUiController.AppearancePageElementId).Visible);
}
[Fact]
public void ProfessionTemplateButton_SelectsTemplate()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template index 1
Assert.Equal(1u, environment.Runtime.LastSelectedTemplate);
}
[Fact]
public void ProfessionSlider_ScalarChange_SetsTheAttribute()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
slider.ScalarChanged!(1f); // top of the [10,100] range
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
Assert.Equal(100, environment.Runtime.LastAttributeValue);
}
[Fact]
public void ProfessionValueField_DirectEntry_SetsTheAttribute()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var field = Assert.IsType<UiField>(
UiElement.FindDescendant(strengthContainer, 0x100002EFu));
field.OnSubmit!("42");
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
Assert.Equal(42, environment.Runtime.LastAttributeValue);
}
[Fact]
public void SkillsRow_Click_TrainsThenSpecializes()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId)
.OnClick!();
// Rows are built in ascending skill-id order (RebuildRows' 1..54
// walk over IsCostable ids) — SkillSpecializable's row is identified
// by its label prefix (FormatSkillLabel's "{name}: ..." shape)
// rather than instance identity, since the controller owns the
// row->skillId map privately.
string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable);
UiButton row = environment.SkillsList().ViewportForTest!.Children
.OfType<UiButton>()
.Single(candidate => candidate.Label!.StartsWith(
skillName + ":", StringComparison.Ordinal));
row.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Trained,
environment.Runtime.GetSkillLevel(SkillSpecializable));
row.OnClick!();
Assert.Equal(ChargenSkillAdvancementClass.Specialized,
environment.Runtime.GetSkillLevel(SkillSpecializable));
row.OnDoubleClick!();
Assert.Equal(ChargenSkillAdvancementClass.Trained,
environment.Runtime.GetSkillLevel(SkillSpecializable));
}
[Fact]
public void TownButton_SelectsTheLiteralStartAreaIndex()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
// Holtburg (0x1000040d) -> startArea 0 per SetTown's literal map.
environment.Button(0x1000040Du).OnClick!();
Assert.Equal(0, environment.Runtime.LastSelectedStartArea);
// Yaraq (0x1000040e) -> startArea 2.
environment.Button(0x1000040Eu).OnClick!();
Assert.Equal(2, environment.Runtime.LastSelectedStartArea);
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
yield return root;
foreach (UiElement child in root.Children)
foreach (UiElement descendant in Descendants(child))
yield return descendant;
}
// ── Fixture ──────────────────────────────────────────────────────────
private sealed class EnvironmentHarness : IDisposable
{
private readonly List<ImportedLayout> _dialogLayouts = [];
public EnvironmentHarness()
{
Host = new UiRoot { Width = 800f, Height = 600f };
Screen = BuildScreen();
Runtime = new FakeRuntime();
Dialogs = new RetailDialogFactory(Host, type =>
{
ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type);
_dialogLayouts.Add(layout);
return layout;
});
Controller = Assert.IsType<CharacterCreationUiController>(
CharacterCreationUiController.CreateDetached(
Host,
Screen,
ResolveSkillRowTemplate,
Dialogs,
Runtime.Bindings,
new CharacterCreationUiController.DialogStrings(
"Are you sure you want to leave?")));
Controller.AttachAndTick();
}
public UiRoot Host { get; }
public ImportedLayout Screen { get; }
public FakeRuntime Runtime { get; }
public RetailDialogFactory Dialogs { get; }
public CharacterCreationUiController Controller { get; }
public UiButton Button(uint id) =>
Assert.IsType<UiButton>(Screen.FindElement(id));
public UiButton TabButton(uint id) => Button(id);
public UiElement Page(uint id) =>
Assert.IsAssignableFrom<UiElement>(Screen.FindElement(id));
public UiTemplateListBox SkillsList() =>
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
/// <summary>Confirms or cancels the MOST RECENTLY opened confirmation
/// dialog, using <see cref="RetailConfirmationDialogView"/>'s real
/// button ids off the layout the factory's <c>createLayout</c>
/// callback actually returned — same lookup shape
/// <c>CharacterManagementUiControllerTests</c> uses.</summary>
public void ConfirmActiveDialog(bool confirmed)
{
ImportedLayout dialog = _dialogLayouts[^1];
uint buttonId = confirmed
? RetailConfirmationDialogView.AcceptButtonId
: RetailConfirmationDialogView.RejectButtonId;
UiButton button = Assert.IsType<UiButton>(dialog.FindElement(buttonId));
button.OnClick!();
}
private static UiElement? ResolveSkillRowTemplate(
uint templateLayoutId,
uint templateElementId) =>
BuildSkillRowTemplate(templateElementId);
public void Dispose()
{
Controller.Dispose();
Dialogs.Dispose();
}
}
private sealed class FakeRuntime
{
private static readonly RuntimeGenerationToken Generation = new(3u);
public FakeRuntime()
{
View = new FakeView(BuildOptions());
Bindings = new CharacterCreationRuntimeBindings(
() => ProvideView ? View : null,
SelectHeritage,
SelectGender,
SelectTemplate,
SetAttribute,
(_, _) => Result(RuntimeCommandStatus.Accepted),
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Trained),
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized),
skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained),
SelectStartArea,
_ => Result(RuntimeCommandStatus.Accepted),
() => RequestExitCalls++,
ResolveText: _ => null,
OpenOnStart: false);
}
public FakeView View { get; }
public CharacterCreationRuntimeBindings Bindings { get; }
public bool ProvideView { get; set; } = true;
public int RequestExitCalls { get; private set; }
public uint LastSelectedHeritage { get; private set; }
public uint LastSelectedGender { get; private set; }
public uint LastSelectedTemplate { get; private set; }
public ChargenAttributeId LastAttributeSet { get; private set; }
public int LastAttributeValue { get; private set; }
public int LastSelectedStartArea { get; private set; } = -1;
public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId);
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
View.GetSkillLevel(skillId);
private RuntimeCommandResult SelectHeritage(uint heritageId)
{
LastSelectedHeritage = heritageId;
View.Snapshot = View.Snapshot with { HeritageId = heritageId };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SelectGender(uint genderKey)
{
LastSelectedGender = genderKey;
View.Snapshot = View.Snapshot with { GenderKey = genderKey };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SelectTemplate(uint templateIndex)
{
LastSelectedTemplate = templateIndex;
View.Snapshot = View.Snapshot with { Template = templateIndex };
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SetAttribute(ChargenAttributeId attribute, int value)
{
LastAttributeSet = attribute;
LastAttributeValue = value;
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SetSkillLevel(
uint skillId,
ChargenSkillAdvancementClass targetClass)
{
View.SetSkillLevel(skillId, targetClass);
return Result(RuntimeCommandStatus.Accepted);
}
private RuntimeCommandResult SelectStartArea(int startAreaIndex)
{
LastSelectedStartArea = startAreaIndex;
View.Snapshot = View.Snapshot with { StartArea = startAreaIndex };
return Result(RuntimeCommandStatus.Accepted);
}
private static RuntimeCommandResult Result(RuntimeCommandStatus status) =>
new(status, Generation);
private static ChargenOptions BuildOptions()
{
var gender = new ChargenGenderOptions(
GenderKey: (int)GenderKey,
Name: "Male",
Scale: 1u,
SetupId: 0x2000054u,
SoundTableId: 0u,
IconId: 0u,
BasePaletteId: 0u,
SkinPalSetId: 0u,
PhysicsTableId: 0u,
MotionTableId: 0u,
CombatTableId: 0u,
BaseObjDesc: ChargenObjDesc.Empty,
HairColors: [],
HairStyles: [],
EyeColors: [],
EyeStrips: [],
NoseStrips: [],
MouthStrips: [],
Headgears: [],
Shirts: [],
Pants: [],
Footwear: [],
ClothingColors: []);
var templates = new List<ChargenTemplate>
{
new(
"Custom",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
NormalSkills: [],
PrimarySkills: []),
new(
"Bow Hunter",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10),
NormalSkills: [SkillTrainOnly],
PrimarySkills: []),
};
var skillCosts = new Dictionary<uint, ChargenSkillCost>
{
[SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6),
[SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6),
};
var aluvian = new ChargenHeritageOptions(
AluvianId,
"Aluvian",
IconId: 0u,
SetupId: 0x2000054u,
EnvironmentSetupId: 0u,
AttributeCredits: 66u,
SkillCredits: 50u,
PrimaryStartAreaIndices: [0, 1],
SecondaryStartAreaIndices: [],
SkillCostsBySkillId: skillCosts,
Templates: templates,
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)GenderKey] = gender });
var olthoi = new ChargenHeritageOptions(
OlthoiId,
"Olthoi",
IconId: 0u,
SetupId: 0x2000054u,
EnvironmentSetupId: 0u,
AttributeCredits: 60u,
SkillCredits: 0u,
PrimaryStartAreaIndices: [0],
SecondaryStartAreaIndices: [],
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>(),
Templates:
[
new ChargenTemplate(
"Custom",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
NormalSkills: [],
PrimarySkills: []),
],
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)GenderKey] = gender });
var starterAreas = new List<ChargenStarterArea>
{
new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]),
new(1, "Shoushi", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]),
new(2, "Yaraq", [new ChargenPosition(3u, Vector3.Zero, Quaternion.Identity)]),
new(3, "Sanamar", [new ChargenPosition(4u, Vector3.Zero, Quaternion.Identity)]),
};
return new ChargenOptions(
starterAreas,
new Dictionary<uint, ChargenHeritageOptions>
{
[AluvianId] = aluvian,
[OlthoiId] = olthoi,
},
new Dictionary<uint, ChargenSkillCost>());
}
}
private sealed class FakeView(ChargenOptions options) : IRuntimeCharacterCreationView
{
private readonly Dictionary<uint, ChargenSkillAdvancementClass> _skillLevels = [];
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } =
new(
new RuntimeGenerationToken(3u),
IsActive: true,
Revision: 1,
HeritageId: 0u,
GenderKey: 0u,
Appearance: RuntimeCharacterCreationAppearance.Default,
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
Attributes: default,
AttributeLockMask: 0u,
TotalAttributeCredits: 66u,
RemainingAttributeCredits: 66,
TotalSkillCredits: 50u,
RemainingSkillCredits: 50,
Name: string.Empty,
StartArea: -1,
Slot: 0u,
VerificationPending: false,
LastLocalRefusal: default,
LastRejection: null,
LastCreated: null);
public ChargenOptions Options { get; } = options;
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
_skillLevels.TryGetValue(skillId, out ChargenSkillAdvancementClass level)
? level
: ChargenSkillAdvancementClass.Inactive;
public void SetSkillLevel(uint skillId, ChargenSkillAdvancementClass level) =>
_skillLevels[skillId] = level;
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) =>
NullSubscription.Instance;
private sealed class NullSubscription : IDisposable
{
public static readonly NullSubscription Instance = new();
public void Dispose() { }
}
}
private static ImportedLayout BuildScreen()
{
var root = new ElementInfo
{
Id = CharacterCreationUiController.RootElementId,
Type = 3u,
Width = 800f,
Height = 600f,
};
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId));
root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId));
root.Children.Add(BuildHeritagePage());
root.Children.Add(BuildProfessionPage());
root.Children.Add(BuildSkillsPage());
root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId));
root.Children.Add(BuildTownPage());
root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId));
root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId));
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
private static ElementInfo BuildHeritagePage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.HeritagePageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(0x100003BFu)); // Aluvian
page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi
page.Children.Add(TextInfo(0x100003C4u));
return page;
}
private static ElementInfo BuildProfessionPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.ProfessionPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(0x100003D9u)); // Custom
page.Children.Add(ButtonInfo(0x100003DAu)); // Bow Hunter
var strengthSlider = ContainerInfo(0x100003E6u);
strengthSlider.Children.Add(ButtonInfo(0x100002ECu));
strengthSlider.Children.Add(ScrollbarInfo(0x100002EEu));
strengthSlider.Children.Add(EditableFieldInfo(0x100002EFu));
page.Children.Add(strengthSlider);
page.Children.Add(ButtonInfo(0x100003E2u)); // Available (consumed-child badge)
page.Children.Add(ButtonInfo(0x100003E3u)); // Health
page.Children.Add(ButtonInfo(0x100003E4u)); // Stamina
page.Children.Add(ButtonInfo(0x100003E5u)); // Mana
return page;
}
private static ElementInfo BuildSkillsPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.SkillsPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
var list = new ElementInfo
{
Id = 0x100003F7u,
Type = 5u,
X = 20f,
Y = 40f,
Width = 300f,
Height = 320f,
};
list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu));
page.Children.Add(list);
page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge
page.Children.Add(TextInfo(0x100003FBu));
page.Children.Add(TextInfo(0x100003FCu));
return page;
}
private static ElementInfo BuildTownPage()
{
var page = new ElementInfo
{
Id = CharacterCreationUiController.TownPageElementId,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(ButtonInfo(0x1000040Bu)); // Sanamar
page.Children.Add(ButtonInfo(0x1000040Du)); // Holtburg
page.Children.Add(ButtonInfo(0x1000040Eu)); // Yaraq
page.Children.Add(ButtonInfo(0x1000040Fu)); // Shoushi
page.Children.Add(TextInfo(0x10000409u));
return page;
}
private static UiElement BuildSkillRowTemplate(uint templateElementId) =>
LayoutImporter.Build(
new ElementInfo
{
Id = templateElementId,
Type = 1u,
Width = 280f,
Height = 16f,
},
_ => (0u, 0, 0),
null).Root;
private static ElementInfo ContainerInfo(uint id) => new()
{
Id = id,
Type = 3u,
Width = 200f,
Height = 60f,
};
private static ElementInfo ButtonInfo(uint id) => new()
{
Id = id,
Type = 1u,
Width = 100f,
Height = 30f,
};
private static ElementInfo TextInfo(uint id) => new()
{
Id = id,
Type = 12u,
Width = 200f,
Height = 60f,
};
private static ElementInfo ScrollbarInfo(uint id) => new()
{
Id = id,
Type = 11u,
Width = 120f,
Height = 12f,
};
private static ElementInfo EditableFieldInfo(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = 12u,
Width = 40f,
Height = 16f,
};
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
state.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
}

View file

@ -57,6 +57,54 @@ public sealed class RuntimeCharacterCreationStateTests
Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
}
// ── Options threading (Campaign CC slice CC4) ───────────────────────
// RuntimeCharacterCreationState.InstallOptions — the App-startup seam
// ContentEffectsAudioCompositionPhase.Compose calls once portal.dat's
// ChargenTableReader.Load result is available, mirroring
// RuntimeCharacterState.InstallSpellMetadata's "install immutable DAT
// metadata after construction" pattern.
[Fact]
public void InstallOptions_BeforeBegin_ReplacesTheOptionsLaterCommandsUse()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
state.InstallOptions(RuntimeCharacterCreationStateFixture.Build());
state.Begin(new RuntimeGenerationToken(1));
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
Assert.Equal(
RuntimeCharacterCreationStateFixture.AluvianId,
state.Snapshot.HeritageId);
}
[Fact]
public void InstallOptions_WhileActive_ThrowsInsteadOfRacingLiveCommands()
{
RuntimeCharacterCreationState state = CreateActive();
Assert.Throws<InvalidOperationException>(
() => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()));
}
[Fact]
public void InstallOptions_NullOptions_Throws()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
Assert.Throws<ArgumentNullException>(() => state.InstallOptions(null!));
}
[Fact]
public void InstallOptions_AfterDispose_Throws()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
state.Dispose();
Assert.Throws<ObjectDisposedException>(
() => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()));
}
// ── Heritage / gender / template ────────────────────────────────────
[Fact]

View file

@ -40,6 +40,61 @@ public sealed class LiveSessionLifecycleHostTests
host.DetachSession(sessionB);
}
/// <summary>Campaign CC slice CC4: <see cref="LiveSessionLifecycleHost.ApplyCharacterCreated"/>/
/// <see cref="LiveSessionLifecycleHost.ApplyCreationFailed"/> forward to
/// the bindings' new optional delegates.</summary>
[Fact]
public void CharacterCreatedAndCreationFailed_ForwardToTheOptionalBindings()
{
var calls = new List<string>();
var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
Bind: session => CreateBinding(session, calls),
Reset: _ => { },
Connecting: (_, _, _) => { },
Connected: () => { },
Roster: _ => { },
Selected: _ => { },
Entered: _ => { },
CharacterCreated: identity => calls.Add($"created:{identity.Guid:X8}:{identity.Name}"),
CreationFailed: rejection => calls.Add($"failed:{rejection.Reason}")));
host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(0x50000001u, "Toon"));
host.ApplyCreationFailed(new RuntimeCharacterCreationRejection(
3u,
AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse,
"NameInUse",
"Toon"));
Assert.Equal(["created:50000001:Toon", "failed:NameInUse"], calls);
}
/// <summary>The two delegates default to <see langword="null"/> — every
/// construction site that predates CC4 keeps compiling and behaves as a
/// no-op, matching <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/>'s
/// own default-interface no-op.</summary>
[Fact]
public void CharacterCreatedAndCreationFailed_DefaultToNoOp_WhenBindingsOmitThem()
{
var calls = new List<string>();
var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
Bind: session => CreateBinding(session, calls),
Reset: _ => { },
Connecting: (_, _, _) => { },
Connected: () => { },
Roster: _ => { },
Selected: _ => { },
Entered: _ => { }));
host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(1u, "Toon"));
host.ApplyCreationFailed(new RuntimeCharacterCreationRejection(
3u,
AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse,
"NameInUse",
"Toon"));
Assert.Empty(calls);
}
[Fact]
public void FailedBindingFactoryDoesNotClaimTheHost()
{