fix(ui): restore retail skill training confirmation

Route untrained-skill promotion through DialogFactory with retail's exact prompt and stored skill/credit properties. Defer the character-sheet refresh until accept or reject completes while leaving trained-skill XP raises immediate.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 17:57:29 +02:00
parent 66bdae7a83
commit 85980fe13f
7 changed files with 341 additions and 14 deletions

View file

@ -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.

View file

@ -195,6 +195,14 @@ public static class CharacterStatController
long Cost,
int Amount);
/// <summary>
/// Handles a retail stat raise and invokes <paramref name="completed"/> when
/// the request has either been applied or cancelled. The completion seam is
/// required by asynchronous retail confirmation dialogs such as
/// <c>gmSkillUI::TrainSkill @ 0x0049C5F0</c>.
/// </summary>
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<uint, (uint handle, int w, int h)>? spriteResolve = null,
Action<RaiseRequest>? 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<IReadOnlyList<SkillRowBinding>> skillRows,
Action<RaiseRequest>? onRaiseRequest,
RaiseRequestHandler? onRaiseRequest,
Action<uint?>? afterRaiseRequest)
{
foreach (var button in allRaise1)
@ -1154,7 +1162,7 @@ public static class CharacterStatController
int[] attrSel,
int[] skillSel,
Func<IReadOnlyList<SkillRowBinding>> skillRows,
Action<RaiseRequest>? onRaiseRequest,
RaiseRequestHandler? onRaiseRequest,
Action<uint?>? 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(

View file

@ -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;
}
/// <summary>

View file

@ -0,0 +1,70 @@
using System.Globalization;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Ports the untrained-skill confirmation owned by
/// <c>gmSkillUI::TrainSkill @ 0x0049C5F0</c> and
/// <c>gmSkillUI::TrainSkillDialogCallback @ 0x0049C210</c>.
/// Already-trained skill XP raises deliberately bypass this controller, matching
/// retail's separate <c>gmSkillUI::RaiseSelection @ 0x0049C8C0</c> path.
/// </summary>
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<CharacterStatController.RaiseRequest> 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();
});
}
}

View file

@ -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;

View file

@ -674,7 +674,8 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
btn1.OnClick!();
@ -696,7 +697,8 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
@ -717,7 +719,8 @@ public class CharacterStatControllerTests
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
list.Children.OfType<UiClickablePanel>().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<UiClickablePanel>().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()
{

View file

@ -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<CharacterStatController.RaiseRequest>();
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<UiText>(shown!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
Assert.IsType<UiButton>(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<CharacterStatController.RaiseRequest>();
bool completed = false;
controller.Request(Request(), Sheet(), sent.Add, () => completed = true);
Assert.IsType<UiButton>(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),
],
};
}