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), + ], + }; +}