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:
parent
3a6b7e3115
commit
0e71d3b829
27 changed files with 3344 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
201
src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs
Normal file
201
src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs
Normal 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 -> heritage-id map), <c>gmCGHeritagePage::Update @
|
||||
/// 0x00483210</c> (description text composition).
|
||||
/// </summary>
|
||||
internal sealed class CharacterCreationHeritagePage : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Button element id -> <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_<Abbrev>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();
|
||||
}
|
||||
}
|
||||
264
src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
Normal file
264
src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
Normal 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 -> 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 -> 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 -> 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();
|
||||
}
|
||||
}
|
||||
210
src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
Normal file
210
src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
Normal 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 -> Trained,
|
||||
/// Trained -> 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 -> Trained,
|
||||
/// Trained -> 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;
|
||||
}
|
||||
}
|
||||
121
src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs
Normal file
121
src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs
Normal 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 ->
|
||||
/// <c>CharGenState::SetStartArea(arg2 - 1)</c> literal index map: Holtburg
|
||||
/// -> 0, Shoushi -> 1, Yaraq -> 2, Sanamar -> 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 -> 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();
|
||||
}
|
||||
}
|
||||
650
src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
Normal file
650
src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 -> 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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue