diff --git a/docs/research/2026-07-13-retail-skill-training-confirmation-pseudocode.md b/docs/research/2026-07-13-retail-skill-training-confirmation-pseudocode.md
new file mode 100644
index 00000000..30848b64
--- /dev/null
+++ b/docs/research/2026-07-13-retail-skill-training-confirmation-pseudocode.md
@@ -0,0 +1,79 @@
+# Retail skill-training confirmation
+
+Date: 2026-07-13
+
+## Scope
+
+This note covers the confirmation shown when an untrained skill is promoted to
+trained by spending skill credits. It does **not** apply when experience is
+added to an already trained or specialized skill; those raises are sent
+immediately.
+
+## Named retail sources
+
+- `gmSkillUI::TrainSkillDialogCallback @ 0x0049C210`
+- `gmSkillUI::TrainSkill @ 0x0049C5F0`
+- `gmSkillUI::RaiseSelection @ 0x0049C8C0`
+- `DialogFactory::MakeCallbackDialogInCurrentUI @ 0x00478430`
+- literal `data_7A7864`: `Are you sure you want to spend %d credits to train %s?`
+
+The dialog property keys are shared with the existing DialogFactory port:
+
+```text
+0x8E dialog type (1 = ConfirmationDialog)
+0x92 confirmation result written by ConfirmationDialog
+0xC5 message StringInfo
+0x10000040 selected skill id
+0x10000041 skill-credit cost
+```
+
+## Pseudocode
+
+```text
+gmSkillUI.TrainSkill(playerQualities):
+ if selectedSkill == 0:
+ return false
+
+ skillName = SkillSystem.InqSkillName(selectedSkill)
+ creditCost = GetCostToRaise(playerQualities)
+ message = format(
+ "Are you sure you want to spend %d credits to train %s?",
+ creditCost,
+ skillName)
+
+ data = PropertyCollection()
+ data[0x8E] = 1 // ConfirmationDialog
+ data[0xC5] = message
+ data[0x10000040] = selectedSkill
+ data[0x10000041] = creditCost
+ DialogFactory.MakeCallbackDialogInCurrentUI(
+ data,
+ gmSkillUI.TrainSkillDialogCallback)
+ return true
+
+gmSkillUI.TrainSkillDialogCallback(data):
+ if data.bool(0x92) is not true:
+ return
+
+ skillId = data.uint(0x10000040)
+ creditCost = data.uint(0x10000041)
+ if skillId != 0 and creditCost != 0:
+ CM_Train.Event_TrainSkillAdvancementClass(skillId, creditCost)
+```
+
+`gmSkillUI::RaiseSelection` branches on advancement class. Untrained skills
+enter `TrainSkill`; trained/specialized skills call `CM_Train::Event_TrainSkill`
+directly with the XP cost. Therefore a confirmation on every skill XP raise
+would be a retail divergence.
+
+## Cross-reference
+
+- ACE `GameActionTrainSkill.Handle` reads exactly two fields, skill id and
+ credits spent, then calls `HandleActionTrainSkill`.
+- Chorizite's generated native client bindings expose
+ `CM_Train::Event_TrainSkillAdvancementClass(UInt32 skill, UInt32 credits)` and
+ separately expose `CM_Train::Event_TrainSkill(UInt32 skill, UInt32 xp)`.
+
+These corroborate the callback's two stored properties and the distinction
+between training a skill and adding XP to a trained skill. The named retail
+client remains the behavioral oracle for when the dialog appears.
diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs
index f73e3042..3a64acd1 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -195,6 +195,14 @@ public static class CharacterStatController
long Cost,
int Amount);
+ ///
+ /// Handles a retail stat raise and invokes when
+ /// the request has either been applied or cancelled. The completion seam is
+ /// required by asynchronous retail confirmation dialogs such as
+ /// gmSkillUI::TrainSkill @ 0x0049C5F0.
+ ///
+ public delegate void RaiseRequestHandler(RaiseRequest request, Action completed);
+
private sealed class TabVisual
{
public required CharacterStatTab Tab { get; init; }
@@ -248,7 +256,7 @@ public static class CharacterStatController
UiDatFont? datFont = null,
UiDatFont? rowDatFont = null,
Func? spriteResolve = null,
- Action? onRaiseRequest = null,
+ RaiseRequestHandler? onRaiseRequest = null,
Action? onClose = null)
{
// rowDatFont: larger font for attribute row name/value text (18px vs 16px default).
@@ -1129,7 +1137,7 @@ public static class CharacterStatController
int[] attrSel,
int[] skillSel,
Func> skillRows,
- Action? onRaiseRequest,
+ RaiseRequestHandler? onRaiseRequest,
Action? afterRaiseRequest)
{
foreach (var button in allRaise1)
@@ -1154,7 +1162,7 @@ public static class CharacterStatController
int[] attrSel,
int[] skillSel,
Func> skillRows,
- Action? onRaiseRequest,
+ RaiseRequestHandler? onRaiseRequest,
Action? afterRaiseRequest)
{
if (onRaiseRequest is null) return;
@@ -1180,8 +1188,7 @@ public static class CharacterStatController
if (request is not { } value) return;
- onRaiseRequest(value);
- afterRaiseRequest?.Invoke(selectedSkillId);
+ onRaiseRequest(value, () => afterRaiseRequest?.Invoke(selectedSkillId));
}
private static RaiseRequest? TryBuildAttributeRaiseRequest(
diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs
index 482b9a8c..debec420 100644
--- a/src/AcDream.App/UI/Layout/RetailDialogData.cs
+++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs
@@ -19,6 +19,8 @@ public static class RetailDialogProperty
public const uint QueueKey = 0xC3u;
public const uint Message = 0xC5u;
public const uint UsageObjectId = 0x1000003Du;
+ public const uint TrainSkillId = 0x10000040u;
+ public const uint TrainSkillCredits = 0x10000041u;
}
///
diff --git a/src/AcDream.App/UI/RetailSkillTrainingConfirmationController.cs b/src/AcDream.App/UI/RetailSkillTrainingConfirmationController.cs
new file mode 100644
index 00000000..dbd28ef7
--- /dev/null
+++ b/src/AcDream.App/UI/RetailSkillTrainingConfirmationController.cs
@@ -0,0 +1,70 @@
+using System.Globalization;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.UI;
+
+///
+/// Ports the untrained-skill confirmation owned by
+/// gmSkillUI::TrainSkill @ 0x0049C5F0 and
+/// gmSkillUI::TrainSkillDialogCallback @ 0x0049C210.
+/// Already-trained skill XP raises deliberately bypass this controller, matching
+/// retail's separate gmSkillUI::RaiseSelection @ 0x0049C8C0 path.
+///
+public sealed class RetailSkillTrainingConfirmationController
+{
+ public const string MessageFormat =
+ "Are you sure you want to spend {0} credits to train {1}?";
+
+ private readonly RetailDialogFactory _dialogs;
+
+ public RetailSkillTrainingConfirmationController(RetailDialogFactory dialogs)
+ => _dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs));
+
+ public uint Request(
+ CharacterStatController.RaiseRequest request,
+ CharacterSheet sheet,
+ Action accepted,
+ Action completed)
+ {
+ ArgumentNullException.ThrowIfNull(sheet);
+ ArgumentNullException.ThrowIfNull(accepted);
+ ArgumentNullException.ThrowIfNull(completed);
+
+ if (request.Kind != CharacterStatController.RaiseTargetKind.TrainSkill)
+ throw new ArgumentException("Only TrainSkill requests require this confirmation.", nameof(request));
+ if (request.Cost <= 0 || request.Cost > uint.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(request), "Training cost must fit retail's uint32 field.");
+
+ CharacterSkill skill = sheet.Skills.FirstOrDefault(candidate => candidate.Id == request.StatId)
+ ?? throw new InvalidOperationException($"Skill {request.StatId} is absent from the character sheet.");
+
+ string message = string.Format(
+ CultureInfo.InvariantCulture,
+ MessageFormat,
+ request.Cost,
+ skill.Name);
+
+ RetailDialogData data = RetailDialogData.Confirmation(message)
+ .Set(RetailDialogProperty.TrainSkillId, request.StatId)
+ .Set(RetailDialogProperty.TrainSkillCredits, checked((uint)request.Cost));
+
+ return _dialogs.MakeDialog(data, result =>
+ {
+ if (result.GetBoolean(RetailDialogProperty.ConfirmationResult))
+ {
+ uint skillId = result.GetUInt32(RetailDialogProperty.TrainSkillId);
+ uint credits = result.GetUInt32(RetailDialogProperty.TrainSkillCredits);
+ if (skillId != 0u && credits != 0u)
+ {
+ accepted(new CharacterStatController.RaiseRequest(
+ CharacterStatController.RaiseTargetKind.TrainSkill,
+ skillId,
+ credits,
+ Amount: 1));
+ }
+ }
+
+ completed();
+ });
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 49967cc3..84e72f36 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -142,6 +142,7 @@ public sealed class RetailUiRuntime : IDisposable
private readonly RetailUiAutomationScriptRunner? _automation;
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
+ private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
@@ -707,6 +708,8 @@ public sealed class RetailUiRuntime : IDisposable
_itemConfirmationController = new RetailItemConfirmationController(
DialogFactory,
ItemInteraction);
+ _skillTrainingConfirmationController =
+ new RetailSkillTrainingConfirmationController(DialogFactory);
Console.WriteLine(
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
}
@@ -806,7 +809,7 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont,
_bindings.Assets.ResolveSprite,
- provider.HandleRaiseRequest,
+ (request, completed) => HandleCharacterRaise(provider, request, completed),
() => CloseWindow(WindowNames.Character));
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
@@ -826,6 +829,30 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
}
+ private void HandleCharacterRaise(
+ CharacterSheetProvider provider,
+ CharacterStatController.RaiseRequest request,
+ Action completed)
+ {
+ if (request.Kind != CharacterStatController.RaiseTargetKind.TrainSkill)
+ {
+ provider.HandleRaiseRequest(request);
+ completed();
+ return;
+ }
+
+ RetailSkillTrainingConfirmationController confirmations =
+ _skillTrainingConfirmationController
+ ?? throw new InvalidOperationException(
+ "The retail dialog factory must be mounted before the character panel.");
+
+ confirmations.Request(
+ request,
+ provider.BuildSheet(),
+ provider.HandleRaiseRequest,
+ completed);
+ }
+
private void MountPlugins()
{
if (_bindings.Plugins is null) return;
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
index df6b303c..11348406 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -674,7 +674,8 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
var requests = new List();
- CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter,
+ onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
list.Children.OfType().ToList()[4].OnClick!();
btn1.OnClick!();
@@ -696,7 +697,8 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
var requests = new List();
- CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter,
+ onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
list.Children.OfType().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
@@ -717,7 +719,8 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
var requests = new List();
- CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter,
+ onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
list.Children.OfType().ToList()[6].OnClick!();
btn1.OnClick!();
@@ -745,7 +748,8 @@ public class CharacterStatControllerTests
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
};
- CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add);
+ CharacterStatController.Bind(layout, () => sheet,
+ onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
list.Children.OfType().ToList()[4].OnClick!();
btn10.OnClick!();
@@ -1004,7 +1008,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16),
- onRaiseRequest: requests.Add);
+ onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
ClickTab(layout, left: 92f);
SkillRows(list)[1].OnClick!();
@@ -1063,7 +1067,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16),
- onRaiseRequest: requests.Add);
+ onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
ClickTab(layout, left: 92f);
SkillRows(list)[5].OnClick!();
@@ -1107,7 +1111,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, () => sheet,
spriteResolve: id => (id, 16, 16),
- onRaiseRequest: request =>
+ onRaiseRequest: (request, completed) =>
{
requests.Add(request);
sheet = new CharacterSheet
@@ -1124,9 +1128,10 @@ public class CharacterStatControllerTests
TrainedCost: 4,
SpecializedCost: 0,
RaiseCost: 10,
- Raise10Cost: 100),
+ Raise10Cost: 100),
},
};
+ completed();
});
ClickTab(layout, left: 92f);
@@ -1144,6 +1149,58 @@ public class CharacterStatControllerTests
Assert.Equal("Normal", btn10.ActiveState);
}
+ [Fact]
+ public void SkillsTab_DeferredTrainRefreshesOnlyWhenRequestCompletes()
+ {
+ var list = new UiPanel { Width = 300 };
+ var btn1 = MakeButton();
+ var btn10 = MakeButton();
+ CharacterSheet sheet = TrainingSheet(CharacterSkillAdvancementClass.Untrained);
+ var layout = Fake(
+ (CharacterStatController.ListBoxId, list),
+ (CharacterStatController.RaiseOneId, btn1),
+ (CharacterStatController.RaiseTenId, btn10));
+ Action? completeRaise = null;
+
+ CharacterStatController.Bind(layout, () => sheet,
+ spriteResolve: id => (id, 16, 16),
+ onRaiseRequest: (_, completed) => completeRaise = completed);
+
+ ClickTab(layout, left: 92f);
+ SkillRows(list).Single().OnClick!();
+ btn1.OnClick!();
+ sheet = TrainingSheet(CharacterSkillAdvancementClass.Trained);
+
+ Assert.NotNull(completeRaise);
+ Assert.False(btn10.Visible);
+
+ completeRaise!();
+
+ Assert.True(btn10.Visible);
+
+ static CharacterSheet TrainingSheet(CharacterSkillAdvancementClass advancement)
+ => new()
+ {
+ SkillCredits = advancement == CharacterSkillAdvancementClass.Untrained ? 10 : 6,
+ UnassignedXp = 1_000,
+ Skills =
+ [
+ new CharacterSkill(
+ 100u,
+ "Train Me",
+ 0x06000001u,
+ advancement,
+ BaseLevel: 5,
+ CurrentLevel: 5,
+ UsableUntrained: true,
+ TrainedCost: 4,
+ SpecializedCost: 0,
+ RaiseCost: advancement == CharacterSkillAdvancementClass.Untrained ? 0 : 10,
+ Raise10Cost: advancement == CharacterSkillAdvancementClass.Untrained ? 0 : 100),
+ ],
+ };
+ }
+
[Fact]
public void SkillsTab_BindsCharacterScrollbarToScrollableViewport()
{
diff --git a/tests/AcDream.App.Tests/UI/RetailSkillTrainingConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailSkillTrainingConfirmationControllerTests.cs
new file mode 100644
index 00000000..f2090859
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/RetailSkillTrainingConfirmationControllerTests.cs
@@ -0,0 +1,85 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.App.Tests.UI.Layout;
+
+namespace AcDream.App.Tests.UI;
+
+public sealed class RetailSkillTrainingConfirmationControllerTests
+{
+ private const uint Healing = 21u;
+
+ [Fact]
+ public void AcceptSendsStoredSkillAndCreditsOnlyAfterConfirmation()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ ImportedLayout? shown = null;
+ var factory = new RetailDialogFactory(root, _ =>
+ shown = FixtureLoader.LoadConfirmationDialog());
+ var controller = new RetailSkillTrainingConfirmationController(factory);
+ var sent = new List();
+ bool completed = false;
+
+ controller.Request(Request(), Sheet(), sent.Add, () => completed = true);
+
+ Assert.Empty(sent);
+ Assert.False(completed);
+ Assert.Equal(
+ "Are you sure you want to spend 6 credits to train Healing?",
+ string.Join(" ", Assert.IsType(shown!.FindElement(
+ RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
+
+ Assert.IsType(shown.FindElement(
+ RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
+
+ CharacterStatController.RaiseRequest request = Assert.Single(sent);
+ Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
+ Assert.Equal(Healing, request.StatId);
+ Assert.Equal(6L, request.Cost);
+ Assert.Equal(1, request.Amount);
+ Assert.True(completed);
+ Assert.False(factory.IsOpen);
+ }
+
+ [Fact]
+ public void RejectCompletesWithoutSendingTrainingRequest()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ ImportedLayout? shown = null;
+ var factory = new RetailDialogFactory(root, _ =>
+ shown = FixtureLoader.LoadConfirmationDialog());
+ var controller = new RetailSkillTrainingConfirmationController(factory);
+ var sent = new List();
+ bool completed = false;
+
+ controller.Request(Request(), Sheet(), sent.Add, () => completed = true);
+ Assert.IsType(shown!.FindElement(
+ RetailConfirmationDialogView.RejectButtonId)).OnClick!();
+
+ Assert.Empty(sent);
+ Assert.True(completed);
+ Assert.False(factory.IsOpen);
+ }
+
+ private static CharacterStatController.RaiseRequest Request()
+ => new(CharacterStatController.RaiseTargetKind.TrainSkill, Healing, 6L, 1);
+
+ private static CharacterSheet Sheet()
+ => new()
+ {
+ SkillCredits = 10,
+ Skills =
+ [
+ new CharacterSkill(
+ Healing,
+ "Healing",
+ 0x06000133u,
+ CharacterSkillAdvancementClass.Untrained,
+ BaseLevel: 10,
+ CurrentLevel: 10,
+ UsableUntrained: true,
+ TrainedCost: 6,
+ SpecializedCost: 10,
+ RaiseCost: 0L),
+ ],
+ };
+}