diff --git a/App/Program.cs b/App/Program.cs
index 25c2145b..9a590cb1 100644
--- a/App/Program.cs
+++ b/App/Program.cs
@@ -28,6 +28,18 @@ internal static class Program
return;
}
- Application.Run(new MainForm(AppOptions.FromArgs(args)));
+ var options = AppOptions.FromArgs(args);
+ var baseDirectory = AppPaths.BaseDirectory;
+ var moduleStore = new ModuleStore(ModuleStore.ResolveModuleDirectory(baseDirectory));
+ var triggerKeyState = new WindowsTriggerKeyState();
+ var runtimeFactory = new ShigureRuntimeFactory(baseDirectory, moduleStore, triggerKeyState);
+ var runtimeSession = new RuntimeSessionCoordinator(runtimeFactory);
+
+ Application.Run(new MainForm(
+ options,
+ baseDirectory,
+ moduleStore,
+ triggerKeyState,
+ runtimeSession));
}
}
diff --git a/App/RuntimeSessionCoordinator.cs b/App/RuntimeSessionCoordinator.cs
new file mode 100644
index 00000000..62d07bc2
--- /dev/null
+++ b/App/RuntimeSessionCoordinator.cs
@@ -0,0 +1,248 @@
+namespace Shigure;
+
+///
+/// 串行管理运行时会话,确保并发的启动、重启和停止请求不会清理到错误的实例。
+///
+internal sealed class RuntimeSessionCoordinator : IAsyncDisposable
+{
+ private readonly IShigureRuntimeFactory _runtimeFactory;
+ private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
+ private readonly object _requestSync = new();
+ private RuntimeSession? _current;
+ private long _nextSessionId;
+ private long _stoppedSessionId;
+ private long _latestRequestVersion;
+ private bool _disposed;
+
+ public RuntimeSessionCoordinator(IShigureRuntimeFactory runtimeFactory)
+ {
+ _runtimeFactory = runtimeFactory;
+ }
+
+ public event Action? SnapshotUpdated;
+
+ public event Action? RuntimeFailed;
+
+ public event Action? RuntimeStopped;
+
+ public bool HasSession => Volatile.Read(ref _current) is not null;
+
+ public bool IsRunning
+ {
+ get
+ {
+ var current = Volatile.Read(ref _current);
+ return current is { RunTask.IsCompleted: false }
+ && Volatile.Read(ref _stoppedSessionId) != current.Id;
+ }
+ }
+
+ public AppOptions? CurrentOptions => Volatile.Read(ref _current)?.Options;
+
+ public long? CurrentSessionId => Volatile.Read(ref _current)?.Id;
+
+ public Task StartAsync(
+ AppOptions options,
+ long requestVersion,
+ CancellationToken cancellationToken = default)
+ => ChangeSessionAsync(options, requestVersion, restart: false, cancellationToken);
+
+ public Task RestartAsync(
+ AppOptions options,
+ long requestVersion,
+ CancellationToken cancellationToken = default)
+ => ChangeSessionAsync(options, requestVersion, restart: true, cancellationToken);
+
+ public async Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await StopCurrentCoreAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
+ }
+
+ public void ToggleEnabled()
+ {
+ var session = Volatile.Read(ref _current);
+ if (session is { RunTask.IsCompleted: false })
+ {
+ session.Runtime.ToggleEnabled();
+ }
+ }
+
+ public void SetEnabled(bool enabled)
+ {
+ var session = Volatile.Read(ref _current);
+ if (session is { RunTask.IsCompleted: false })
+ {
+ session.Runtime.SetEnabled(enabled);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _lifecycleGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ await StopCurrentCoreAsync().ConfigureAwait(false);
+ _disposed = true;
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
+ }
+
+ private async Task ChangeSessionAsync(
+ AppOptions options,
+ long requestVersion,
+ bool restart,
+ CancellationToken cancellationToken)
+ {
+ RegisterLatestRequest(requestVersion);
+ await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (requestVersion != Volatile.Read(ref _latestRequestVersion))
+ {
+ return;
+ }
+
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var current = _current;
+ if (!restart
+ && current is not null
+ && IsRunning
+ && current.Options == options)
+ {
+ return;
+ }
+
+ if (current is not null)
+ {
+ await StopCurrentCoreAsync().ConfigureAwait(false);
+ }
+
+ if (requestVersion != Volatile.Read(ref _latestRequestVersion))
+ {
+ return;
+ }
+
+ StartCore(options, requestVersion);
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
+ }
+
+ private void RegisterLatestRequest(long requestVersion)
+ {
+ lock (_requestSync)
+ {
+ if (requestVersion > _latestRequestVersion)
+ {
+ Volatile.Write(ref _latestRequestVersion, requestVersion);
+ }
+ }
+ }
+
+ private void StartCore(AppOptions options, long requestVersion)
+ {
+ var runtime = _runtimeFactory.Create(options);
+ var cancellation = new CancellationTokenSource();
+ var sessionId = Interlocked.Increment(ref _nextSessionId);
+ Action snapshotHandler = snapshot => SnapshotUpdated?.Invoke(sessionId, snapshot);
+
+ lock (_requestSync)
+ {
+ if (requestVersion != _latestRequestVersion)
+ {
+ cancellation.Dispose();
+ return;
+ }
+
+ runtime.SnapshotUpdated += snapshotHandler;
+ try
+ {
+ var runTask = Task.Run(() => RunRuntimeAsync(sessionId, runtime, cancellation.Token));
+ Volatile.Write(
+ ref _current,
+ new RuntimeSession(sessionId, options, runtime, cancellation, runTask, snapshotHandler));
+ }
+ catch
+ {
+ runtime.SnapshotUpdated -= snapshotHandler;
+ cancellation.Dispose();
+ throw;
+ }
+ }
+ }
+
+ private async Task StopCurrentCoreAsync()
+ {
+ var session = _current;
+ if (session is null)
+ {
+ return;
+ }
+
+ session.Cancellation.Cancel();
+ try
+ {
+ await session.RunTask.ConfigureAwait(false);
+ }
+ finally
+ {
+ session.Runtime.SnapshotUpdated -= session.SnapshotHandler;
+ session.Cancellation.Dispose();
+ if (ReferenceEquals(Volatile.Read(ref _current), session))
+ {
+ Volatile.Write(ref _current, null);
+ }
+ }
+ }
+
+ private async Task RunRuntimeAsync(
+ long sessionId,
+ ShigureRuntime runtime,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await runtime.RunAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // 正常停止路径。
+ }
+ catch (Exception ex)
+ {
+ RuntimeFailed?.Invoke(sessionId, ex);
+ }
+ finally
+ {
+ Volatile.Write(ref _stoppedSessionId, sessionId);
+ RuntimeStopped?.Invoke(sessionId);
+ }
+ }
+
+ private sealed record RuntimeSession(
+ long Id,
+ AppOptions Options,
+ ShigureRuntime Runtime,
+ CancellationTokenSource Cancellation,
+ Task RunTask,
+ Action SnapshotHandler);
+}
diff --git a/App/ShigureRuntimeFactory.cs b/App/ShigureRuntimeFactory.cs
new file mode 100644
index 00000000..c24d5a11
--- /dev/null
+++ b/App/ShigureRuntimeFactory.cs
@@ -0,0 +1,42 @@
+namespace Shigure;
+
+internal interface IShigureRuntimeFactory
+{
+ ShigureRuntime Create(AppOptions options);
+}
+
+internal sealed class ShigureRuntimeFactory : IShigureRuntimeFactory
+{
+ private readonly string _baseDirectory;
+ private readonly ModuleStore _moduleStore;
+ private readonly ITriggerKeyState _triggerKeyState;
+ private readonly TimeProvider _timeProvider;
+
+ public ShigureRuntimeFactory(
+ string baseDirectory,
+ ModuleStore moduleStore,
+ ITriggerKeyState triggerKeyState,
+ TimeProvider? timeProvider = null)
+ {
+ _baseDirectory = baseDirectory;
+ _moduleStore = moduleStore;
+ _triggerKeyState = triggerKeyState;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ public ShigureRuntime Create(AppOptions options)
+ {
+ _moduleStore.Reload();
+ var config = ConfigService.LoadFromBaseDirectory(_baseDirectory);
+ var keymap = new KeymapService(_baseDirectory, config);
+
+ return new ShigureRuntime(
+ options,
+ new PixelScanner(options.WindowTitle),
+ new StateBuilder(config),
+ new KeySender(options.WindowTitle),
+ _triggerKeyState,
+ new LogicRegistry(keymap, _moduleStore, options.ModuleId),
+ _timeProvider);
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c748b62..649a1fec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,7 +37,7 @@ PixelScanner.ScanScreenData() Runtime/PixelScanner.cs 截屏读像
↓
StateBuilder.Build(rowData, barData) Runtime/StateBuilder.cs 按 config 把像素翻译成 GameState 字段
↓ GameState (Runtime/GameState.cs: Values / Spells / Group)
-LogicRegistry.Run(classId, specId, ...) Modules/LogicRegistry.cs
+LogicRegistry.Evaluate(classId, specId, ...) Modules/LogicRegistry.cs
├─ 命中模块 → ModuleLogic.Run(module, state, keymap)
├─ 否则该职业注册了 IClassLogic → 它
└─ 否则 DefaultClassLogic
@@ -45,13 +45,15 @@ LogicRegistry.Run(classId, specId, ...) Modules/LogicRegistry.cs
KeySender.Send(hotkey) Input/KeySender.cs (+ Input/NativeMethods.cs Win32 互操作)
```
-UI 侧:[UI/MainForm.cs](UI/MainForm.cs) 是无边框置顶浮动条,托管 `ShigureRuntime` 和 [UI/StatusForm.cs](UI/StatusForm.cs)(九页签设置窗口:通用/配置/宏/模块/状态/队伍/逻辑/日志/关于)。运行时通过 `SnapshotUpdated` 事件推送 `RenderSnapshot` 给 UI 刷新。
+应用组合根在 [App/Program.cs](App/Program.cs):统一创建 `ModuleStore`、Win32 触发键适配器、`ShigureRuntimeFactory` 与 `RuntimeSessionCoordinator`。后者串行管理运行时的启动/重启/停止,避免 UI 的并发设置事件互相清理会话。[UI/MainForm.cs](UI/MainForm.cs) 是无边框置顶浮动条,把运行意图和生命周期交给协调器,并展示 [UI/StatusForm.cs](UI/StatusForm.cs)(九页签设置窗口:通用/配置/宏/模块/状态/队伍/逻辑/日志/关于)。运行时通过 `SnapshotUpdated` 事件推送 `RenderSnapshot` 给 UI 刷新。
+
+`ShigureRuntime` 不创建具体 I/O 依赖;生产依赖由 `ShigureRuntimeFactory` 注入,窄端口定义在 [Runtime/RuntimeDependencies.cs](Runtime/RuntimeDependencies.cs)。外部启停请求先进入命令队列,只有 `RunAsync` 循环能修改运行状态。改动运行时依赖或生命周期时保留这两个约束。
### 目录约定
```
-App/ 入口、启动参数、随机重启
-Runtime/ 扫描、状态构建、主循环、快照
+App/ 入口、启动参数、随机重启、依赖组装、运行时会话协调
+Runtime/ 扫描、状态构建、主循环、运行时端口、快照
Modules/ 模块模型/存储/匹配/规则执行、条件求值(FormulaEvaluator)、字段目录、职业逻辑
Input/ keymap 读取、按键发送、Win32 API
Infrastructure/ 配置读取(ConfigService)、JSON 辅助、UI 缓存、路径、Fuyutsui 插件文件读写
@@ -62,6 +64,7 @@ config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .c
## 模块解析(改逻辑前必读)
- 模块以 `module/模块名.json` 平铺保存,**文件名取自模块名,故模块名不可重复**;加载递归扫描子目录。模型在 [Modules/ModuleStore.cs](Modules/ModuleStore.cs)(`ModuleDefinition`/`ModuleMatch`/`ModuleRule`/`ModuleUnit`/`ModuleCountField`/`ModuleValueAdjustment`)。`RecommendedTalent` 是 `ModuleDefinition` 上的纯展示字段,不参与匹配(`ModuleMatch.Specificity` 不计入)。
+- `ModuleStore` 的 `Reload`/`Save`/`Delete` 会在同一个门锁内串行完整文件事务与内存快照更新;`Save` 通过同目录临时文件提交,重命名失败会回滚新文件。编辑器写入不要绕过它,避免运行时重载读到半次操作。
- 选择优先级:`ModuleStore.FindSelectedOrBestMatch` —— 先用 UI/参数选定的 `ModuleId`;否则取 `Match` 命中字段最多者(`ModuleMatch.Specificity` 越大越优先),并列按名称。`Match` 字段留空 = 任意。`PartyType` 数字会归一化为 `"1-40"`。
- 动态单位/数量/动态数值的语义见 [README.md](README.md#动态单位与数量字段);列表与编辑器的人类可读摘要统一走 [UI/UnitSummary.cs](UI/UnitSummary.cs)`.Describe(...)`(单一来源,勿再复制一份描述逻辑)。
@@ -100,6 +103,7 @@ config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .c
- [UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs):左侧职业列表 + 右侧按专精切换的四页编辑器(状态/光环/法术/队伍),状态字段用 `ClassStateCatalog` 驱动的 `ComboBoxColumn`。
- [UI/ClassMacrosEditorControl.cs](UI/ClassMacrosEditorControl.cs):左侧职业列表 + 右侧三页编辑器(动态宏/静态宏/特殊宏),偏移提示显示槽位编号计算。
- 两个编辑器均接受 `Func` 定位器 + `Func` 保存回调,由 `MainForm` 在构造时注入。保存流程:编辑器调 `Store.Save()` → 回调 `MainForm.UpdateConfigFromAddonAsync()` → 重新生成 config/keymap → 重启运行时。
+- `UpdateConfigFromAddonAsync` 的多个入口通过任务尾队列串行执行;运行时重启会等待该队列稳定,主窗口关闭也会等待正在写盘的转换完成。新增同步入口必须继续走这条队列。
## UI 约定
diff --git a/Input/KeySender.cs b/Input/KeySender.cs
index 545aeea0..944d9e77 100644
--- a/Input/KeySender.cs
+++ b/Input/KeySender.cs
@@ -1,66 +1,9 @@
namespace Shigure;
-public sealed class KeySender
+public sealed class KeySender : IRuntimeKeyOutput
{
private readonly string _windowTitle;
- private static readonly Dictionary Vk = new(StringComparer.OrdinalIgnoreCase)
- {
- ["SHIFT"] = 0x10,
- ["CONTROL"] = 0x11,
- ["CTRL"] = 0x11,
- ["MENU"] = 0x12,
- ["ALT"] = 0x12,
- ["XBUTTON1"] = 0x05,
- ["X1"] = 0x05,
- ["MOUSE4"] = 0x05,
- ["XBUTTON2"] = 0x06,
- ["X2"] = 0x06,
- ["MOUSE5"] = 0x06,
- ["F1"] = 0x70,
- ["F2"] = 0x71,
- ["F3"] = 0x72,
- ["F4"] = 0x73,
- ["F5"] = 0x74,
- ["F6"] = 0x75,
- ["F7"] = 0x76,
- ["F8"] = 0x77,
- ["F9"] = 0x78,
- ["F10"] = 0x79,
- ["F11"] = 0x7A,
- ["F12"] = 0x7B,
- ["NUMPAD0"] = 0x60,
- ["NUMPAD1"] = 0x61,
- ["NUMPAD2"] = 0x62,
- ["NUMPAD3"] = 0x63,
- ["NUMPAD4"] = 0x64,
- ["NUMPAD5"] = 0x65,
- ["NUMPAD6"] = 0x66,
- ["NUMPAD7"] = 0x67,
- ["NUMPAD8"] = 0x68,
- ["NUMPAD9"] = 0x69,
- ["NUMPADDECIMAL"] = 0x6E,
- ["NUMPADPLUS"] = 0x6B,
- ["NUMPADMINUS"] = 0x6D,
- ["NUMPADMULTIPLY"] = 0x6A,
- ["NUMPADDIVIDE"] = 0x6F
- };
-
- private static readonly Dictionary CharVk = new()
- {
- [","] = 0xBC,
- ["."] = 0xBE,
- ["/"] = 0xBF,
- [";"] = 0xBA,
- ["'"] = 0xDE,
- ["["] = 0xDB,
- ["]"] = 0xDD,
- ["="] = 0xBB,
- ["-"] = 0xBD,
- ["`"] = 0xC0,
- ["\\"] = 0xDC
- };
-
public KeySender(string windowTitle)
{
_windowTitle = windowTitle;
@@ -70,14 +13,24 @@ public sealed class KeySender
public bool Send(string hotkey)
{
- LastFailureReason = null;
+ var result = SendCore(hotkey);
+ LastFailureReason = result.FailureReason;
+ return result.Succeeded;
+ }
+
+ public static int? GetVk(string keyName) => WindowsVirtualKeyMap.Resolve(keyName);
+
+ KeySendResult IRuntimeKeyOutput.Send(string hotkey) => SendCore(hotkey);
+
+ private KeySendResult SendCore(string hotkey)
+ {
var (mods, mainKey) = ParseHotkey(hotkey);
if (mainKey is null)
{
return Fail($"无法解析按键“{hotkey}”");
}
- var vkMain = GetVk(mainKey);
+ var vkMain = WindowsVirtualKeyMap.Resolve(mainKey);
if (vkMain is null)
{
return Fail($"无法识别主键“{mainKey}”");
@@ -89,9 +42,9 @@ public sealed class KeySender
return Fail($"未找到目标窗口“{_windowTitle}”");
}
- // ParseHotkey 只产出去重后的 CTRL/ALT/SHIFT, 三者都在 Vk 表里且映射到互异 VK,
- // 故 GetVk 不会为 null、结果天然去重。
- var modVks = mods.Select(m => GetVk(m)!.Value).ToList();
+ // ParseHotkey 只产出去重后的 CTRL/ALT/SHIFT, 三者都在虚拟键表里且映射到互异 VK,
+ // 故 Resolve 不会为 null、结果天然去重。
+ var modVks = mods.Select(m => WindowsVirtualKeyMap.Resolve(m)!.Value).ToList();
var succeeded = true;
var firstError = 0;
@@ -122,7 +75,7 @@ public sealed class KeySender
if (succeeded)
{
- return true;
+ return KeySendResult.Success;
}
return Fail(firstError == 5
@@ -130,36 +83,6 @@ public sealed class KeySender
: $"向目标窗口发送按键失败,Win32 错误码: {firstError}");
}
- public static int? GetVk(string keyName)
- {
- if (string.IsNullOrWhiteSpace(keyName))
- {
- return null;
- }
-
- var key = keyName.Trim();
- if (Vk.TryGetValue(key, out var mapped))
- {
- return mapped;
- }
-
- if (key.Length == 1)
- {
- if (CharVk.TryGetValue(key, out var charMapped))
- {
- return charMapped;
- }
-
- var scan = NativeMethods.VkKeyScanW(key[0]);
- if (scan != -1)
- {
- return scan & 0xFF;
- }
- }
-
- return null;
- }
-
private static (List Mods, string? MainKey) ParseHotkey(string hotkey)
{
if (string.IsNullOrWhiteSpace(hotkey))
@@ -194,11 +117,7 @@ public sealed class KeySender
return (mods, mainKey);
}
- private bool Fail(string reason)
- {
- LastFailureReason = reason;
- return false;
- }
+ private static KeySendResult Fail(string reason) => KeySendResult.Failure(reason);
private static bool Post(nint hwnd, int keyCode, bool keyUp, out int error)
{
diff --git a/Input/KeymapService.cs b/Input/KeymapService.cs
index bae0730f..d25923aa 100644
--- a/Input/KeymapService.cs
+++ b/Input/KeymapService.cs
@@ -3,7 +3,7 @@ using System.Text.Json.Nodes;
namespace Shigure;
-public sealed class KeymapService
+public sealed class KeymapService : IKeymapResolver
{
private readonly string _baseDirectory;
private readonly ConfigService _config;
diff --git a/Input/WindowsTriggerKeyState.cs b/Input/WindowsTriggerKeyState.cs
new file mode 100644
index 00000000..5c003173
--- /dev/null
+++ b/Input/WindowsTriggerKeyState.cs
@@ -0,0 +1,8 @@
+namespace Shigure;
+
+internal sealed class WindowsTriggerKeyState : ITriggerKeyState
+{
+ public int? ResolveVirtualKey(string keyName) => WindowsVirtualKeyMap.Resolve(keyName);
+
+ public bool IsPressed(int virtualKey) => NativeMethods.IsKeyDown(virtualKey);
+}
diff --git a/Input/WindowsVirtualKeyMap.cs b/Input/WindowsVirtualKeyMap.cs
new file mode 100644
index 00000000..6291ee55
--- /dev/null
+++ b/Input/WindowsVirtualKeyMap.cs
@@ -0,0 +1,88 @@
+namespace Shigure;
+
+internal static class WindowsVirtualKeyMap
+{
+ private static readonly Dictionary NamedKeys = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["SHIFT"] = 0x10,
+ ["CONTROL"] = 0x11,
+ ["CTRL"] = 0x11,
+ ["MENU"] = 0x12,
+ ["ALT"] = 0x12,
+ ["XBUTTON1"] = 0x05,
+ ["X1"] = 0x05,
+ ["MOUSE4"] = 0x05,
+ ["XBUTTON2"] = 0x06,
+ ["X2"] = 0x06,
+ ["MOUSE5"] = 0x06,
+ ["F1"] = 0x70,
+ ["F2"] = 0x71,
+ ["F3"] = 0x72,
+ ["F4"] = 0x73,
+ ["F5"] = 0x74,
+ ["F6"] = 0x75,
+ ["F7"] = 0x76,
+ ["F8"] = 0x77,
+ ["F9"] = 0x78,
+ ["F10"] = 0x79,
+ ["F11"] = 0x7A,
+ ["F12"] = 0x7B,
+ ["NUMPAD0"] = 0x60,
+ ["NUMPAD1"] = 0x61,
+ ["NUMPAD2"] = 0x62,
+ ["NUMPAD3"] = 0x63,
+ ["NUMPAD4"] = 0x64,
+ ["NUMPAD5"] = 0x65,
+ ["NUMPAD6"] = 0x66,
+ ["NUMPAD7"] = 0x67,
+ ["NUMPAD8"] = 0x68,
+ ["NUMPAD9"] = 0x69,
+ ["NUMPADDECIMAL"] = 0x6E,
+ ["NUMPADPLUS"] = 0x6B,
+ ["NUMPADMINUS"] = 0x6D,
+ ["NUMPADMULTIPLY"] = 0x6A,
+ ["NUMPADDIVIDE"] = 0x6F
+ };
+
+ private static readonly Dictionary CharacterKeys = new()
+ {
+ [","] = 0xBC,
+ ["."] = 0xBE,
+ ["/"] = 0xBF,
+ [";"] = 0xBA,
+ ["'"] = 0xDE,
+ ["["] = 0xDB,
+ ["]"] = 0xDD,
+ ["="] = 0xBB,
+ ["-"] = 0xBD,
+ ["`"] = 0xC0,
+ ["\\"] = 0xDC
+ };
+
+ public static int? Resolve(string keyName)
+ {
+ if (string.IsNullOrWhiteSpace(keyName))
+ {
+ return null;
+ }
+
+ var key = keyName.Trim();
+ if (NamedKeys.TryGetValue(key, out var mapped))
+ {
+ return mapped;
+ }
+
+ if (key.Length != 1)
+ {
+ return null;
+ }
+
+ if (CharacterKeys.TryGetValue(key, out var characterMapped))
+ {
+ return characterMapped;
+ }
+
+ var scan = NativeMethods.VkKeyScanW(key[0]);
+ return scan == -1 ? null : scan & 0xFF;
+ }
+}
diff --git a/Modules/IKeymapResolver.cs b/Modules/IKeymapResolver.cs
new file mode 100644
index 00000000..833d1bef
--- /dev/null
+++ b/Modules/IKeymapResolver.cs
@@ -0,0 +1,12 @@
+namespace Shigure;
+
+public interface IKeymapResolver
+{
+ void SelectForClass(int? classId, int? specId);
+
+ string? GetHotkey(int? unit, string spell);
+
+ IReadOnlyDictionary GetCurrentFailedSpells();
+
+ IReadOnlyDictionary GetCurrentOneKeySpells();
+}
diff --git a/Modules/LogicRegistry.cs b/Modules/LogicRegistry.cs
index 2bf6248c..b2956168 100644
--- a/Modules/LogicRegistry.cs
+++ b/Modules/LogicRegistry.cs
@@ -1,61 +1,59 @@
namespace Shigure;
-public sealed record LogicDecision(
- string? Hotkey,
- string Step,
- IReadOnlyDictionary UnitInfo,
- string? ModuleName = null,
- int DelayMs = 0,
- string? RateLimitKey = null,
- int LogicDelayMs = 0);
-
public interface IClassLogic
{
LogicDecision Run(GameState state, string? specName);
}
-public sealed class LogicRegistry
+public sealed class LogicRegistry : IRuntimeLogic
{
- private readonly Dictionary _logicByClass = new();
+ private readonly Dictionary _logicByClass;
private readonly IClassLogic _defaultLogic;
- private readonly KeymapService _keymap;
+ private readonly IKeymapResolver _keymap;
private readonly ModuleStore _moduleStore;
private readonly string? _selectedModuleId;
- public LogicRegistry(KeymapService keymap, ModuleStore moduleStore, string? selectedModuleId)
+ public LogicRegistry(
+ IKeymapResolver keymap,
+ ModuleStore moduleStore,
+ string? selectedModuleId,
+ IEnumerable>? classLogics = null)
{
_keymap = keymap;
_moduleStore = moduleStore;
_selectedModuleId = string.IsNullOrWhiteSpace(selectedModuleId) ? null : selectedModuleId.Trim();
_defaultLogic = new DefaultClassLogic(keymap);
+ _logicByClass = classLogics?.ToDictionary(pair => pair.Key, pair => pair.Value) ?? new();
}
- public LogicDecision Run(int? classId, int? specId, string? specName, GameState state)
+ public LogicEvaluation Evaluate(
+ int? classId,
+ int? specId,
+ string? specName,
+ GameState state,
+ bool runLogic)
{
+ _keymap.SelectForClass(classId, specId);
var module = FindModule(classId, specId, state);
if (module is not null)
{
- return ModuleLogic.Run(module, state, _keymap);
+ ModuleLogic.ResolveDynamicFields(module, state);
+ return new LogicEvaluation(
+ module.Name,
+ runLogic ? ModuleLogic.Run(module, state, _keymap) : null);
+ }
+
+ if (!runLogic)
+ {
+ return new LogicEvaluation(null, null);
}
if (classId is not null && _logicByClass.TryGetValue(classId.Value, out var logic))
{
- return logic.Run(state, specName);
+ return new LogicEvaluation(null, logic.Run(state, specName));
}
- return _defaultLogic.Run(state, specName);
- }
-
- public string? ResolveDynamicState(int? classId, int? specId, GameState state)
- {
- var module = FindModule(classId, specId, state);
- if (module is null)
- {
- return null;
- }
-
- ModuleLogic.ResolveDynamicFields(module, state);
- return module.Name;
+ return new LogicEvaluation(null, _defaultLogic.Run(state, specName));
}
private ModuleDefinition? FindModule(int? classId, int? specId, GameState state)
@@ -71,9 +69,9 @@ public sealed class LogicRegistry
public sealed class DefaultClassLogic : IClassLogic
{
- private readonly KeymapService _keymap;
+ private readonly IKeymapResolver _keymap;
- public DefaultClassLogic(KeymapService keymap)
+ public DefaultClassLogic(IKeymapResolver keymap)
{
_keymap = keymap;
}
diff --git a/Modules/ModuleStore.cs b/Modules/ModuleStore.cs
index 1686fafb..2a965c62 100644
--- a/Modules/ModuleStore.cs
+++ b/Modules/ModuleStore.cs
@@ -305,30 +305,30 @@ public sealed class ModuleStore
public void Reload()
{
- Directory.CreateDirectory(ModuleDirectory);
- var loaded = new List();
- foreach (var file in Directory.EnumerateFiles(ModuleDirectory, "*.json", SearchOption.AllDirectories))
- {
- try
- {
- var module = JsonSerializer.Deserialize(File.ReadAllText(file), JsonOptions);
- if (module is null)
- {
- continue;
- }
-
- Normalize(module);
- module.FilePath = file;
- loaded.Add(module);
- }
- catch
- {
- // 单个模块损坏时跳过,避免影响其它模块加载。
- }
- }
-
lock (_gate)
{
+ Directory.CreateDirectory(ModuleDirectory);
+ var loaded = new List();
+ foreach (var file in Directory.EnumerateFiles(ModuleDirectory, "*.json", SearchOption.AllDirectories))
+ {
+ try
+ {
+ var module = JsonSerializer.Deserialize(File.ReadAllText(file), JsonOptions);
+ if (module is null)
+ {
+ continue;
+ }
+
+ Normalize(module);
+ module.FilePath = file;
+ loaded.Add(module);
+ }
+ catch
+ {
+ // 单个模块损坏时跳过,避免影响其它模块加载。
+ }
+ }
+
_modules = SortModules(loaded).ToList();
}
}
@@ -375,47 +375,65 @@ public sealed class ModuleStore
{
throw new InvalidOperationException($"模块名称“{module.Name}”已存在。");
}
- }
- if (File.Exists(path)
- && (string.IsNullOrWhiteSpace(oldPath) || !PathsEqual(oldPath, path)))
- {
- throw new InvalidOperationException($"模块文件“{Path.GetFileName(path)}”已存在,请使用其他名称。");
- }
+ if (File.Exists(path)
+ && (string.IsNullOrWhiteSpace(oldPath) || !PathsEqual(oldPath, path)))
+ {
+ throw new InvalidOperationException($"模块文件“{Path.GetFileName(path)}”已存在,请使用其他名称。");
+ }
- if (!string.IsNullOrWhiteSpace(oldPath)
- && IsInsideModuleDirectory(oldPath)
- && !PathsEqual(oldPath, path)
- && File.Exists(oldPath))
- {
- File.Delete(oldPath);
- }
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ WriteFileAtomically(path, JsonSerializer.Serialize(module, JsonOptions));
- Directory.CreateDirectory(Path.GetDirectoryName(path)!);
- File.WriteAllText(path, JsonSerializer.Serialize(module, JsonOptions));
- module.FilePath = path;
+ if (!string.IsNullOrWhiteSpace(oldPath)
+ && IsInsideModuleDirectory(oldPath)
+ && !PathsEqual(oldPath, path)
+ && File.Exists(oldPath))
+ {
+ try
+ {
+ File.Delete(oldPath);
+ }
+ catch (Exception deleteError)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception rollbackError)
+ {
+ throw new AggregateException(
+ "模块已写入新文件,但旧文件删除失败,且无法回滚新文件。",
+ deleteError,
+ rollbackError);
+ }
- lock (_gate)
- {
+ throw;
+ }
+ }
+
+ module.FilePath = path;
_modules.RemoveAll(existing =>
string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(existing.FilePath, path, StringComparison.OrdinalIgnoreCase));
_modules.Add(module.Clone());
_modules = SortModules(_modules).ToList();
- }
- return module.Clone();
+ return module.Clone();
+ }
}
public void Delete(ModuleDefinition module)
{
- if (!string.IsNullOrWhiteSpace(module.FilePath) && IsInsideModuleDirectory(module.FilePath) && File.Exists(module.FilePath))
- {
- File.Delete(module.FilePath);
- }
-
lock (_gate)
{
+ if (!string.IsNullOrWhiteSpace(module.FilePath)
+ && IsInsideModuleDirectory(module.FilePath)
+ && File.Exists(module.FilePath))
+ {
+ File.Delete(module.FilePath);
+ }
+
_modules.RemoveAll(existing =>
string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(existing.FilePath, module.FilePath, StringComparison.OrdinalIgnoreCase));
@@ -442,6 +460,43 @@ public sealed class ModuleStore
}
}
+ private static void WriteFileAtomically(string path, string content)
+ {
+ var directory = Path.GetDirectoryName(path)!;
+ var tempPath = Path.Combine(
+ directory,
+ $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
+
+ try
+ {
+ File.WriteAllText(tempPath, content);
+ if (File.Exists(path))
+ {
+ File.Move(tempPath, path, overwrite: true);
+ }
+ else
+ {
+ File.Move(tempPath, path);
+ }
+ }
+ catch
+ {
+ try
+ {
+ if (File.Exists(tempPath))
+ {
+ File.Delete(tempPath);
+ }
+ }
+ catch
+ {
+ // 保留原始写入异常;残留临时文件不会被模块扫描加载。
+ }
+
+ throw;
+ }
+ }
+
private static IEnumerable SortModules(IEnumerable modules)
{
return modules
@@ -617,7 +672,7 @@ public sealed class ModuleStore
public static class ModuleLogic
{
- public static LogicDecision Run(ModuleDefinition module, GameState state, KeymapService keymap)
+ public static LogicDecision Run(ModuleDefinition module, GameState state, IKeymapResolver keymap)
{
var info = CreateInfo(module, state);
var unitSlots = ResolveDynamicFields(module, state);
diff --git a/UI/UnitSelector.cs b/Modules/UnitSelector.cs
similarity index 100%
rename from UI/UnitSelector.cs
rename to Modules/UnitSelector.cs
diff --git a/Runtime/PixelScanner.cs b/Runtime/PixelScanner.cs
index ddaf305e..541b4cb2 100644
--- a/Runtime/PixelScanner.cs
+++ b/Runtime/PixelScanner.cs
@@ -10,7 +10,7 @@ public sealed record ScreenScanResult(
IReadOnlyDictionary HealAbsorbData,
string? FailureReason);
-public sealed class PixelScanner
+public sealed class PixelScanner : IRuntimeScreenScanner
{
private const int TopRowBlockCount = 510;
private const int TopRowFirstSchemeMax = 255;
diff --git a/Runtime/RuntimeDependencies.cs b/Runtime/RuntimeDependencies.cs
new file mode 100644
index 00000000..2133163a
--- /dev/null
+++ b/Runtime/RuntimeDependencies.cs
@@ -0,0 +1,54 @@
+namespace Shigure;
+
+public sealed record LogicDecision(
+ string? Hotkey,
+ string Step,
+ IReadOnlyDictionary UnitInfo,
+ string? ModuleName = null,
+ int DelayMs = 0,
+ string? RateLimitKey = null,
+ int LogicDelayMs = 0);
+
+internal interface IRuntimeScreenScanner
+{
+ ScreenScanResult ScanScreenData();
+}
+
+internal interface IRuntimeStateBuilder
+{
+ GameState Build(
+ IReadOnlyDictionary rowData,
+ IReadOnlyDictionary barData,
+ IReadOnlyDictionary? healAbsorbData = null);
+}
+
+internal interface IRuntimeLogic
+{
+ LogicEvaluation Evaluate(
+ int? classId,
+ int? specId,
+ string? specName,
+ GameState state,
+ bool runLogic);
+}
+
+public sealed record LogicEvaluation(string? ModuleName, LogicDecision? Decision);
+
+internal interface IRuntimeKeyOutput
+{
+ KeySendResult Send(string hotkey);
+}
+
+public readonly record struct KeySendResult(bool Succeeded, string? FailureReason)
+{
+ public static KeySendResult Success { get; } = new(true, null);
+
+ public static KeySendResult Failure(string reason) => new(false, reason);
+}
+
+internal interface ITriggerKeyState
+{
+ int? ResolveVirtualKey(string keyName);
+
+ bool IsPressed(int virtualKey);
+}
diff --git a/Runtime/ShigureRuntime.cs b/Runtime/ShigureRuntime.cs
index 36557070..ba756945 100644
--- a/Runtime/ShigureRuntime.cs
+++ b/Runtime/ShigureRuntime.cs
@@ -5,12 +5,13 @@ namespace Shigure;
public sealed class ShigureRuntime
{
private readonly AppOptions _options;
- private readonly ConfigService _config;
- private readonly KeymapService _keymap;
- private readonly PixelScanner _scanner;
- private readonly StateBuilder _stateBuilder;
- private readonly KeySender _keySender;
- private readonly LogicRegistry _logicRegistry;
+ private readonly IRuntimeScreenScanner _scanner;
+ private readonly IRuntimeStateBuilder _stateBuilder;
+ private readonly IRuntimeKeyOutput _keySender;
+ private readonly ITriggerKeyState _triggerKeyState;
+ private readonly IRuntimeLogic _logic;
+ private readonly TimeProvider _timeProvider;
+ private readonly ConcurrentQueue _pendingCommands = new();
private GameState? _state;
private string? _className;
@@ -23,18 +24,25 @@ public sealed class ShigureRuntime
private IReadOnlyDictionary _unitInfo = new Dictionary();
private bool _enabled;
private bool _clickPending;
- private readonly ConcurrentDictionary _lastRuleSentAt = new(StringComparer.Ordinal);
+ private readonly Dictionary _lastRuleSentAt = new(StringComparer.Ordinal);
private DateTimeOffset _logicPausedUntil = DateTimeOffset.MinValue;
- public ShigureRuntime(string baseDirectory, AppOptions options, ModuleStore moduleStore)
+ internal ShigureRuntime(
+ AppOptions options,
+ IRuntimeScreenScanner scanner,
+ IRuntimeStateBuilder stateBuilder,
+ IRuntimeKeyOutput keySender,
+ ITriggerKeyState triggerKeyState,
+ IRuntimeLogic logic,
+ TimeProvider timeProvider)
{
_options = options;
- _config = ConfigService.LoadFromBaseDirectory(baseDirectory);
- _keymap = new KeymapService(baseDirectory, _config);
- _scanner = new PixelScanner(options.WindowTitle);
- _stateBuilder = new StateBuilder(_config);
- _keySender = new KeySender(options.WindowTitle);
- _logicRegistry = new LogicRegistry(_keymap, moduleStore, options.ModuleId);
+ _scanner = scanner;
+ _stateBuilder = stateBuilder;
+ _keySender = keySender;
+ _triggerKeyState = triggerKeyState;
+ _logic = logic;
+ _timeProvider = timeProvider;
}
public event Action? SnapshotUpdated;
@@ -42,6 +50,16 @@ public sealed class ShigureRuntime
public AppOptions Options => _options;
public void SetEnabled(bool enabled)
+ {
+ _pendingCommands.Enqueue(RuntimeCommand.SetEnabled(enabled));
+ }
+
+ public void ToggleEnabled()
+ {
+ _pendingCommands.Enqueue(RuntimeCommand.ToggleEnabled());
+ }
+
+ private void ApplyEnabled(bool enabled)
{
_enabled = enabled;
_clickPending = false;
@@ -55,9 +73,25 @@ public sealed class ShigureRuntime
PublishSnapshot();
}
+ private void DrainPendingCommands()
+ {
+ while (_pendingCommands.TryDequeue(out var command))
+ {
+ switch (command.Kind)
+ {
+ case RuntimeCommandKind.SetEnabled:
+ ApplyEnabled(command.Enabled);
+ break;
+ case RuntimeCommandKind.ToggleEnabled:
+ ApplyEnabled(!_enabled);
+ break;
+ }
+ }
+ }
+
public async Task RunAsync(CancellationToken cancellationToken = default)
{
- var toggleVk = KeySender.GetVk(_options.ToggleKey);
+ var toggleVk = _triggerKeyState.ResolveVirtualKey(_options.ToggleKey);
if (toggleVk is null)
{
_currentStep = $"无法识别触发键: {_options.ToggleKey}";
@@ -76,8 +110,9 @@ public sealed class ShigureRuntime
{
while (!cancellationToken.IsCancellationRequested)
{
- var now = DateTimeOffset.UtcNow;
- var pressed = NativeMethods.IsKeyDown(toggleVk.Value);
+ DrainPendingCommands();
+ var now = _timeProvider.GetUtcNow();
+ var pressed = _triggerKeyState.IsPressed(toggleVk.Value);
var rising = pressed && !previousPressed && now - lastToggleAt >= TimeSpan.FromMilliseconds(120);
var falling = !pressed && previousPressed;
@@ -115,7 +150,7 @@ public sealed class ShigureRuntime
PublishSnapshot();
}
- await Task.Delay(25, cancellationToken);
+ await Task.Delay(TimeSpan.FromMilliseconds(25), _timeProvider, cancellationToken);
}
}
finally
@@ -181,8 +216,6 @@ public sealed class ShigureRuntime
_classId = _state.GetInt("职业");
_specId = _state.GetInt("专精");
(_className, _specName) = ClassNames.GetClassAndSpecName(_classId, _specId);
- _keymap.SelectForClass(_classId, _specId);
-
if (!_state.GetBool("有效性"))
{
_moduleName = null;
@@ -191,7 +224,8 @@ public sealed class ShigureRuntime
return;
}
- _moduleName = _logicRegistry.ResolveDynamicState(_classId, _specId, _state);
+ var evaluation = _logic.Evaluate(_classId, _specId, _specName, _state, _enabled);
+ _moduleName = evaluation.ModuleName;
if (!_enabled)
{
@@ -199,7 +233,14 @@ public sealed class ShigureRuntime
return;
}
- var decision = _logicRegistry.Run(_classId, _specId, _specName, _state);
+ var decision = evaluation.Decision;
+ if (decision is null)
+ {
+ _currentStep = "逻辑未返回决策";
+ _unitInfo = new Dictionary();
+ return;
+ }
+
_currentStep = decision.Step;
_unitInfo = decision.UnitInfo;
_moduleName = decision.ModuleName;
@@ -207,10 +248,13 @@ public sealed class ShigureRuntime
if (_options.Mode == SendMode.Click)
{
if (_clickPending
- && !string.IsNullOrWhiteSpace(decision.Hotkey)
- && CanSend(decision, DateTimeOffset.UtcNow))
+ && !string.IsNullOrWhiteSpace(decision.Hotkey))
{
- SendAndPauseLogic(decision);
+ var sendAttemptAt = _timeProvider.GetUtcNow();
+ if (CanSend(decision, sendAttemptAt))
+ {
+ SendAndPauseLogic(decision);
+ }
}
_enabled = false;
@@ -218,30 +262,36 @@ public sealed class ShigureRuntime
return;
}
- if (!string.IsNullOrWhiteSpace(decision.Hotkey)
- && CanSend(decision, DateTimeOffset.UtcNow))
+ if (!string.IsNullOrWhiteSpace(decision.Hotkey))
{
- SendAndPauseLogic(decision);
+ var sendAttemptAt = _timeProvider.GetUtcNow();
+ if (CanSend(decision, sendAttemptAt))
+ {
+ SendAndPauseLogic(decision);
+ }
}
}
private void SendAndPauseLogic(LogicDecision decision)
{
- if (!_keySender.Send(decision.Hotkey!))
+ var sendResult = _keySender.Send(decision.Hotkey!);
+ if (!sendResult.Succeeded)
{
var info = _unitInfo.ToDictionary(
entry => entry.Key,
entry => entry.Value,
StringComparer.Ordinal);
- info["发送失败"] = _keySender.LastFailureReason ?? "未知原因";
+ info["发送失败"] = sendResult.FailureReason ?? "未知原因";
_unitInfo = info;
_currentStep = $"{decision.Step}(按键发送失败)";
return;
}
+ var sentAt = _timeProvider.GetUtcNow();
+ RecordSent(decision, sentAt);
if (decision.LogicDelayMs > 0)
{
- _logicPausedUntil = DateTimeOffset.UtcNow.AddMilliseconds(decision.LogicDelayMs);
+ _logicPausedUntil = sentAt.AddMilliseconds(decision.LogicDelayMs);
}
}
@@ -261,10 +311,22 @@ public sealed class ShigureRuntime
return false;
}
- _lastRuleSentAt[key] = now;
return true;
}
+ private void RecordSent(LogicDecision decision, DateTimeOffset now)
+ {
+ if (decision.DelayMs <= 0)
+ {
+ return;
+ }
+
+ var key = string.IsNullOrWhiteSpace(decision.RateLimitKey)
+ ? decision.Hotkey ?? string.Empty
+ : decision.RateLimitKey;
+ _lastRuleSentAt[key] = now;
+ }
+
private void PublishSnapshot()
{
SnapshotUpdated?.Invoke(new RenderSnapshot(
@@ -353,4 +415,19 @@ public sealed class ShigureRuntime
_ => value.ToString() ?? "-"
};
}
+
+ private enum RuntimeCommandKind
+ {
+ SetEnabled,
+ ToggleEnabled
+ }
+
+ private readonly record struct RuntimeCommand(RuntimeCommandKind Kind, bool Enabled)
+ {
+ public static RuntimeCommand SetEnabled(bool enabled)
+ => new(RuntimeCommandKind.SetEnabled, enabled);
+
+ public static RuntimeCommand ToggleEnabled()
+ => new(RuntimeCommandKind.ToggleEnabled, false);
+ }
}
diff --git a/Runtime/StateBuilder.cs b/Runtime/StateBuilder.cs
index f748c79c..6b93c01f 100644
--- a/Runtime/StateBuilder.cs
+++ b/Runtime/StateBuilder.cs
@@ -2,7 +2,7 @@
namespace Shigure;
-public sealed class StateBuilder
+public sealed class StateBuilder : IRuntimeStateBuilder
{
private readonly ConfigService _config;
diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs
index 39445041..d068145c 100644
--- a/UI/ClassConfigEditorControl.cs
+++ b/UI/ClassConfigEditorControl.cs
@@ -1614,12 +1614,20 @@ public sealed class ClassConfigEditorControl : UserControl
_dirty = false;
_statusLabel.Text = "已写入 Lua,正在更新配置…";
await _updateConfigAsync();
+ if (IsDisposed)
+ {
+ return;
+ }
+
_statusLabel.Text = "已保存并更新配置";
}
catch (Exception ex)
{
- MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
- _statusLabel.Text = $"保存失败: {ex.Message}";
+ if (!IsDisposed)
+ {
+ MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ _statusLabel.Text = $"保存失败: {ex.Message}";
+ }
}
}
diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs
index cb014e25..f7694b0a 100644
--- a/UI/ClassMacrosEditorControl.cs
+++ b/UI/ClassMacrosEditorControl.cs
@@ -941,13 +941,21 @@ public sealed class ClassMacrosEditorControl : UserControl
_dirty = false;
_statusLabel.Text = "已写入 Lua,正在更新配置…";
await _updateConfigAsync();
+ if (IsDisposed)
+ {
+ return;
+ }
+
_statusLabel.Text = "已保存并更新配置";
UpdateOffsetHint();
}
catch (Exception ex)
{
- MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
- _statusLabel.Text = $"保存失败: {ex.Message}";
+ if (!IsDisposed)
+ {
+ MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ _statusLabel.Text = $"保存失败: {ex.Message}";
+ }
}
}
diff --git a/UI/MainForm.cs b/UI/MainForm.cs
index 8c2af7c3..6e4fadda 100644
--- a/UI/MainForm.cs
+++ b/UI/MainForm.cs
@@ -48,16 +48,16 @@ public sealed class MainForm : Form, IMessageFilter
private Color? _currentHeaderIconColor;
private readonly StatusForm _statusForm;
+ private readonly string _baseDirectory;
private readonly ModuleStore _moduleStore;
+ private readonly ITriggerKeyState _triggerKeyState;
+ private readonly RuntimeSessionCoordinator _runtimeSession;
private readonly ModuleEditorControl _moduleEditor;
private readonly ClassConfigEditorControl _classConfigEditor;
private readonly ClassMacrosEditorControl _classMacrosEditor;
private readonly AppOptions _initialOptions;
private readonly UiCacheState _uiCache;
private readonly System.Windows.Forms.Timer _roundedCornerResizeTimer;
- private ShigureRuntime? _runtime;
- private CancellationTokenSource? _runtimeCts;
- private Task? _runtimeTask;
private RenderSnapshot? _lastSnapshot;
private string? _lastLoggedStep;
private string? _lastLoggedStepDetails;
@@ -65,12 +65,25 @@ public sealed class MainForm : Form, IMessageFilter
private string? _lastLoggedClass;
private string? _lastLoggedModule;
private bool? _lastLoggedEnabled;
+ private readonly object _configUpdateSync = new();
+ private Task _configUpdateTail = Task.CompletedTask;
+ private long _runtimeRequestVersion;
+ private bool _shutdownStarted;
+ private bool _shutdownCompleted;
- public MainForm(AppOptions? initialOptions = null)
+ internal MainForm(
+ AppOptions initialOptions,
+ string baseDirectory,
+ ModuleStore moduleStore,
+ ITriggerKeyState triggerKeyState,
+ RuntimeSessionCoordinator runtimeSession)
{
- _initialOptions = initialOptions ?? AppOptions.FromArgs(Array.Empty());
+ _initialOptions = initialOptions;
+ _baseDirectory = baseDirectory;
+ _moduleStore = moduleStore;
+ _triggerKeyState = triggerKeyState;
+ _runtimeSession = runtimeSession;
_uiCache = UiCacheStore.Load();
- _moduleStore = new ModuleStore(ModuleStore.ResolveModuleDirectory(AppPaths.BaseDirectory));
_statusForm = new StatusForm();
_roundedCornerResizeTimer = new System.Windows.Forms.Timer
{
@@ -87,7 +100,7 @@ public sealed class MainForm : Form, IMessageFilter
Application.AddMessageFilter(this);
InitializeComponent();
_statusForm.AttachSettingsPanel(BuildSettingsPanel());
- _moduleEditor = new ModuleEditorControl(_moduleStore, RestartRuntimeFromEditor, AppPaths.BaseDirectory);
+ _moduleEditor = new ModuleEditorControl(_moduleStore, RestartRuntimeFromEditorAsync, _baseDirectory);
_statusForm.AttachModuleEditor(_moduleEditor);
_classConfigEditor = new ClassConfigEditorControl(
() => WowAddonLocator.FindClassDirectory(_initialOptions.WindowTitle),
@@ -106,6 +119,9 @@ public sealed class MainForm : Form, IMessageFilter
ApplyCachedWindowState();
ApplyInitialOptions();
WireSettingEvents();
+ _runtimeSession.SnapshotUpdated += HandleSnapshotUpdated;
+ _runtimeSession.RuntimeFailed += HandleRuntimeFailed;
+ _runtimeSession.RuntimeStopped += HandleRuntimeStopped;
SetRuntimeControls(running: false);
AppendLog("界面已就绪");
}
@@ -118,18 +134,30 @@ public sealed class MainForm : Form, IMessageFilter
_usesDwmRoundedCorners = UiTheme.ApplyRoundedCorners(this);
}
- protected override void OnShown(EventArgs e)
+ protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
- StartRuntime();
+ await StartRuntimeAsync();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
- SaveUiCache();
- _roundedCornerResizeTimer.Stop();
- Application.RemoveMessageFilter(this);
- _runtimeCts?.Cancel();
+ if (!_shutdownCompleted)
+ {
+ e.Cancel = true;
+ if (!_shutdownStarted)
+ {
+ _shutdownStarted = true;
+ SaveUiCache();
+ _roundedCornerResizeTimer.Stop();
+ Application.RemoveMessageFilter(this);
+ _ = CompleteShutdownAsync();
+ }
+
+ base.OnFormClosing(e);
+ return;
+ }
+
base.OnFormClosing(e);
}
@@ -139,6 +167,32 @@ public sealed class MainForm : Form, IMessageFilter
base.OnFormClosed(e);
}
+ private async Task CompleteShutdownAsync()
+ {
+ _runtimeSession.SnapshotUpdated -= HandleSnapshotUpdated;
+ _runtimeSession.RuntimeFailed -= HandleRuntimeFailed;
+ _runtimeSession.RuntimeStopped -= HandleRuntimeStopped;
+
+ try
+ {
+ var runtimeShutdown = _runtimeSession.DisposeAsync().AsTask();
+ await Task.WhenAll(runtimeShutdown, GetPendingConfigUpdateTask());
+ }
+ catch (Exception ex)
+ {
+ AppendLog($"停止运行失败: {ex.Message}");
+ }
+ finally
+ {
+ _statusForm.Dispose();
+ _shutdownCompleted = true;
+ if (!IsDisposed)
+ {
+ Close();
+ }
+ }
+ }
+
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
@@ -555,8 +609,69 @@ public sealed class MainForm : Form, IMessageFilter
return panel;
}
- private async Task UpdateConfigFromAddonAsync()
+ private Task UpdateConfigFromAddonAsync()
{
+ lock (_configUpdateSync)
+ {
+ if (_shutdownStarted)
+ {
+ return Task.CompletedTask;
+ }
+
+ _configUpdateTail = RunQueuedConfigUpdateAsync(_configUpdateTail);
+ return _configUpdateTail;
+ }
+ }
+
+ private async Task RunQueuedConfigUpdateAsync(Task previousUpdate)
+ {
+ await Task.Yield();
+ try
+ {
+ await previousUpdate;
+ }
+ catch
+ {
+ // 前一个调用方会收到自己的异常;队列仍继续处理后续更新。
+ }
+
+ if (!_shutdownStarted)
+ {
+ await UpdateConfigFromAddonCoreAsync();
+ }
+ }
+
+ private Task GetPendingConfigUpdateTask()
+ {
+ lock (_configUpdateSync)
+ {
+ return _configUpdateTail;
+ }
+ }
+
+ private async Task WaitForPendingConfigUpdatesAsync()
+ {
+ while (true)
+ {
+ var pending = GetPendingConfigUpdateTask();
+ await pending;
+ lock (_configUpdateSync)
+ {
+ if (ReferenceEquals(pending, _configUpdateTail))
+ {
+ return;
+ }
+ }
+ }
+ }
+
+ private async Task UpdateConfigFromAddonCoreAsync()
+ {
+ if (_shutdownStarted)
+ {
+ return;
+ }
+
var windowTitle = _initialOptions.WindowTitle;
var classDirectory = WowAddonLocator.FindClassDirectory(windowTitle);
var classMacrosPath = WowAddonLocator.FindClassMacrosPath(windowTitle);
@@ -574,14 +689,14 @@ public sealed class MainForm : Form, IMessageFilter
_configSourceLabel.Text = string.IsNullOrWhiteSpace(classMacrosPath)
? $"Fuyutsui class: {classDirectory}"
: $"Fuyutsui: {classDirectory} + classmacros.lua";
- var configDirectory = ConfigService.ResolveConfigPath(AppPaths.BaseDirectory);
+ var configDirectory = ConfigService.ResolveConfigPath(_baseDirectory);
if (!Directory.Exists(configDirectory))
{
MessageBox.Show($"配置目录不存在: {configDirectory}", "更新配置", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
- var keymapDirectory = Path.Combine(AppPaths.BaseDirectory, "keymap");
+ var keymapDirectory = Path.Combine(_baseDirectory, "keymap");
try
{
@@ -598,6 +713,11 @@ public sealed class MainForm : Form, IMessageFilter
return (Config: configResult, Keymap: keymapResult);
});
+ if (_shutdownStarted)
+ {
+ return;
+ }
+
_moduleEditor.ReloadCatalogs();
AppendLog($"已从 Fuyutsui 更新配置: {result.Config.UpdatedFiles.Count} 个文件 ← {result.Config.ClassDirectory}");
foreach (var warning in result.Config.Warnings.Take(20))
@@ -618,11 +738,15 @@ public sealed class MainForm : Form, IMessageFilter
AppendLog("未找到 core\\classmacros.lua,已跳过 keymap 更新");
}
- if (_runtime is not null)
+ if (_runtimeSession.HasSession)
{
AppendLog("配置已更新, 重新启动运行");
- await StopRuntimeAsync();
- StartRuntime();
+ await StartOrRestartRuntimeAsync(restart: true, waitForConfigUpdates: false);
+ }
+
+ if (_shutdownStarted)
+ {
+ return;
}
var warningCount = result.Config.Warnings.Count + (result.Keymap?.Warnings.Count ?? 0);
@@ -640,6 +764,11 @@ public sealed class MainForm : Form, IMessageFilter
}
catch (Exception ex)
{
+ if (_shutdownStarted)
+ {
+ return;
+ }
+
AppendLog($"更新配置失败: {ex.Message}");
MessageBox.Show(ex.Message, "更新配置失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
@@ -681,132 +810,132 @@ public sealed class MainForm : Form, IMessageFilter
await RestartRuntimeAfterSettingChangeAsync();
}
- private void StartRuntime()
+ private async Task StartRuntimeAsync()
{
- if (_runtimeTask is { IsCompleted: false })
+ if (_runtimeSession.IsRunning)
{
return;
}
+ await StartOrRestartRuntimeAsync(restart: false);
+ }
+
+ private async Task StartOrRestartRuntimeAsync(
+ bool restart,
+ bool waitForConfigUpdates = true)
+ {
+ if (_shutdownStarted)
+ {
+ return false;
+ }
+
var options = BuildOptions();
- if (IsUnsupportedToggleKey(options.ToggleKey))
+ if (!ValidateRuntimeOptions(options))
{
- MessageBox.Show("触发键不支持 ALT,请选择其他按键。", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning);
- return;
+ return false;
}
- if (KeySender.GetVk(options.ToggleKey) is null)
- {
- MessageBox.Show($"无法识别触发键: {options.ToggleKey}", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning);
- return;
- }
+ var requestVersion = Interlocked.Increment(ref _runtimeRequestVersion);
try
{
- _runtimeCts = new CancellationTokenSource();
- _moduleStore.Reload();
- _runtime = new ShigureRuntime(AppPaths.BaseDirectory, options, _moduleStore);
- _runtime.SnapshotUpdated += HandleSnapshotUpdated;
- _runtimeTask = Task.Run(() => RunRuntimeAsync(_runtime, _runtimeCts.Token));
+ if (waitForConfigUpdates)
+ {
+ await WaitForPendingConfigUpdatesAsync();
+ if (_shutdownStarted || requestVersion != Volatile.Read(ref _runtimeRequestVersion))
+ {
+ return false;
+ }
+ }
+
+ if (restart)
+ {
+ await _runtimeSession.RestartAsync(options, requestVersion);
+ }
+ else
+ {
+ await _runtimeSession.StartAsync(options, requestVersion);
+ }
}
catch (Exception ex)
{
- MessageBox.Show(ex.Message, "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
- AppendLog($"启动失败: {ex.Message}");
- return;
+ if (_shutdownStarted || requestVersion != Volatile.Read(ref _runtimeRequestVersion))
+ {
+ return false;
+ }
+
+ var operation = restart ? "重启" : "启动";
+ MessageBox.Show(ex.Message, $"{operation}失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ AppendLog($"{operation}失败: {ex.Message}");
+ SetRuntimeControls(running: _runtimeSession.IsRunning);
+ return false;
}
+ if (_shutdownStarted || requestVersion != Volatile.Read(ref _runtimeRequestVersion))
+ {
+ return false;
+ }
+
+ if (!_runtimeSession.IsRunning)
+ {
+ SetRuntimeControls(running: false);
+ return false;
+ }
+
+ ResetRuntimeLogState();
+ SetRuntimeControls(running: true);
+ AppendLog($"运行已{(restart ? "重启" : "启动")}: {options.WindowTitle} / {options.ToggleKey} / {ModeLabel(options.Mode)}");
+ return true;
+ }
+
+ private bool ValidateRuntimeOptions(AppOptions options)
+ {
+ if (IsUnsupportedToggleKey(options.ToggleKey))
+ {
+ MessageBox.Show("触发键不支持 ALT,请选择其他按键。", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return false;
+ }
+
+ if (_triggerKeyState.ResolveVirtualKey(options.ToggleKey) is null)
+ {
+ MessageBox.Show($"无法识别触发键: {options.ToggleKey}", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return false;
+ }
+
+ return true;
+ }
+
+ private void ResetRuntimeLogState()
+ {
_lastLoggedStep = null;
_lastLoggedStepDetails = null;
_lastLoggedScanFailureReason = null;
_lastLoggedClass = null;
_lastLoggedModule = null;
_lastLoggedEnabled = null;
- SetRuntimeControls(running: true);
- AppendLog($"运行已启动: {options.WindowTitle} / {options.ToggleKey} / {ModeLabel(options.Mode)}");
}
- private async Task RunRuntimeAsync(ShigureRuntime runtime, CancellationToken cancellationToken)
- {
- try
- {
- await runtime.RunAsync(cancellationToken);
- }
- catch (OperationCanceledException)
- {
- // Normal stop path.
- }
- catch (Exception ex)
- {
- PostToUi(() =>
- {
- AppendLog($"运行异常: {ex.Message}");
- _titleLabel.ForeColor = UiTheme.Danger;
- });
- }
- finally
- {
- PostToUi(() => SetRuntimeControls(running: false));
- }
- }
-
- private async Task StopRuntimeAsync()
- {
- if (_runtimeCts is null)
- {
- return;
- }
-
- _runtimeCts.Cancel();
-
- if (_runtimeTask is not null)
- {
- try
- {
- await _runtimeTask;
- }
- catch (OperationCanceledException)
- {
- // Already handled by the runtime task.
- }
- }
-
- if (_runtime is not null)
- {
- _runtime.SnapshotUpdated -= HandleSnapshotUpdated;
- }
-
- _runtimeCts.Dispose();
- _runtimeCts = null;
- _runtimeTask = null;
- _runtime = null;
- SetRuntimeControls(running: false);
- AppendLog("运行已停止");
- }
-
- private async void RestartRuntimeFromEditor()
+ private async Task RestartRuntimeFromEditorAsync()
{
RefreshModuleSelector(_lastSnapshot, reloadModules: true);
- if (_runtime is null)
+ if (!_runtimeSession.HasSession)
{
_moduleStore.Reload();
return;
}
AppendLog("模块已变更, 重新启动运行");
- await StopRuntimeAsync();
- StartRuntime();
+ await StartOrRestartRuntimeAsync(restart: true);
}
private void ToggleEnabled()
{
- if (_runtime is null)
+ if (!_runtimeSession.IsRunning)
{
return;
}
- var nextEnabled = !(_lastSnapshot?.Enabled ?? false);
- _runtime.SetEnabled(nextEnabled);
+ _runtimeSession.ToggleEnabled();
}
private AppOptions BuildOptions()
@@ -828,9 +957,41 @@ public sealed class MainForm : Form, IMessageFilter
};
}
- private void HandleSnapshotUpdated(RenderSnapshot snapshot)
+ private void HandleSnapshotUpdated(long sessionId, RenderSnapshot snapshot)
{
- PostToUi(() => ApplySnapshot(snapshot));
+ PostToUi(() =>
+ {
+ if (_runtimeSession.CurrentSessionId == sessionId)
+ {
+ ApplySnapshot(snapshot);
+ }
+ });
+ }
+
+ private void HandleRuntimeFailed(long sessionId, Exception exception)
+ {
+ PostToUi(() =>
+ {
+ if (_runtimeSession.CurrentSessionId != sessionId)
+ {
+ return;
+ }
+
+ AppendLog($"运行异常: {exception.Message}");
+ _titleLabel.ForeColor = UiTheme.Danger;
+ SetRuntimeControls(running: false);
+ });
+ }
+
+ private void HandleRuntimeStopped(long sessionId)
+ {
+ PostToUi(() =>
+ {
+ if (_runtimeSession.CurrentSessionId == sessionId)
+ {
+ SetRuntimeControls(running: false);
+ }
+ });
}
private void ApplySnapshot(RenderSnapshot snapshot)
@@ -973,14 +1134,13 @@ public sealed class MainForm : Form, IMessageFilter
private async Task RestartRuntimeAfterSettingChangeAsync()
{
var options = BuildOptions();
- if (_runtime is not null && options == _runtime.Options)
+ if (_runtimeSession.IsRunning && options == _runtimeSession.CurrentOptions)
{
return;
}
AppendLog("设置已变更, 重新启动运行");
- await StopRuntimeAsync();
- StartRuntime();
+ await StartOrRestartRuntimeAsync(restart: _runtimeSession.HasSession);
}
private void WriteSnapshotLog(RenderSnapshot snapshot)
@@ -1353,7 +1513,7 @@ public sealed class MainForm : Form, IMessageFilter
return NativeMethods.HtClient;
}
- private static string? TryMapKeyToHotkey(Keys key)
+ private string? TryMapKeyToHotkey(Keys key)
{
var keyName = key.ToString().ToUpperInvariant();
if (IsUnsupportedToggleKey(keyName))
@@ -1389,7 +1549,7 @@ public sealed class MainForm : Form, IMessageFilter
"SUBTRACT" => "NUMPADMINUS",
"MULTIPLY" => "NUMPADMULTIPLY",
"DIVIDE" => "NUMPADDIVIDE",
- _ => KeySender.GetVk(keyName) is not null ? keyName : null
+ _ => _triggerKeyState.ResolveVirtualKey(keyName) is not null ? keyName : null
};
}
diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs
index 30eb51c5..e6aa9b7f 100644
--- a/UI/ModuleEditorControl.cs
+++ b/UI/ModuleEditorControl.cs
@@ -9,7 +9,7 @@ public sealed class ModuleEditorControl : UserControl
private const string ModuleWebsiteUrl = "https://www.shigure.club";
private readonly ModuleStore _moduleStore;
- private readonly Action _runtimeRestartRequested;
+ private readonly Func _runtimeRestartRequested;
private readonly string _baseDirectory;
private ConditionFieldCatalog _fieldCatalog;
private KeymapCatalog _keymapCatalog;
@@ -35,6 +35,7 @@ public sealed class ModuleEditorControl : UserControl
private readonly Label _editorEmptyHint = new();
private Button _saveButton = null!;
private Button _deleteButton = null!;
+ private Button _addButton = null!;
private readonly ToolTip _rulesGridToolTip = new()
{
InitialDelay = 300,
@@ -53,6 +54,7 @@ public sealed class ModuleEditorControl : UserControl
private bool _suppressUnitsColumnResize;
// 载入时程序化写入"类型"单元格会触发 CellValueChanged; 置真以跳过"按类型清空数值"的联动。
private bool _suppressAdjustmentTypeChange;
+ private bool _moduleCommandInProgress;
// 规则行拖拽重排: 拖动起始行, 以及拖动中的插入指示位置(显示一条强调线)。
private int _dragSourceRow = -1;
private int _dragIndicatorRow = -1;
@@ -82,7 +84,7 @@ public sealed class ModuleEditorControl : UserControl
("动态单位", ConditionFieldCategory.DynamicUnit)
];
- public ModuleEditorControl(ModuleStore moduleStore, Action runtimeRestartRequested, string baseDirectory)
+ public ModuleEditorControl(ModuleStore moduleStore, Func runtimeRestartRequested, string baseDirectory)
{
_moduleStore = moduleStore;
_runtimeRestartRequested = runtimeRestartRequested;
@@ -2557,19 +2559,19 @@ public sealed class ModuleEditorControl : UserControl
_saveButton = UiTheme.CreateButton("保存", UiTheme.Accent, Color.Black);
_saveButton.Margin = new Padding(8, 0, 0, 0);
- _saveButton.Click += (_, _) => SaveSelectedModule();
+ _saveButton.Click += async (_, _) => await RunModuleCommandAsync(SaveSelectedModuleAsync);
_deleteButton = UiTheme.CreateButton("删除", UiTheme.Field, UiTheme.Danger);
_deleteButton.Margin = new Padding(8, 0, 0, 0);
- _deleteButton.Click += (_, _) => DeleteSelectedModule();
+ _deleteButton.Click += async (_, _) => await RunModuleCommandAsync(DeleteSelectedModuleAsync);
- var addButton = UiTheme.CreateButton("新建", UiTheme.Field, UiTheme.Text);
- addButton.Margin = new Padding(8, 0, 0, 0);
- addButton.Click += (_, _) => AddModule();
+ _addButton = UiTheme.CreateButton("新建", UiTheme.Field, UiTheme.Text);
+ _addButton.Margin = new Padding(8, 0, 0, 0);
+ _addButton.Click += async (_, _) => await RunModuleCommandAsync(AddModuleAsync);
buttons.Controls.Add(_saveButton);
buttons.Controls.Add(_deleteButton);
- buttons.Controls.Add(addButton);
+ buttons.Controls.Add(_addButton);
row.Controls.Add(hint);
row.Controls.Add(buttons);
return row;
@@ -2697,8 +2699,9 @@ public sealed class ModuleEditorControl : UserControl
// 无选中模块时禁用保存/删除(否则点了静默无反应), 并在编辑区显示引导提示。
private void SetEditorEnabled(bool hasModule)
{
- _saveButton.Enabled = hasModule;
- _deleteButton.Enabled = hasModule;
+ _saveButton.Enabled = hasModule && !_moduleCommandInProgress;
+ _deleteButton.Enabled = hasModule && !_moduleCommandInProgress;
+ _addButton.Enabled = !_moduleCommandInProgress;
_editorEmptyHint.Visible = !hasModule;
if (!hasModule)
{
@@ -2706,7 +2709,37 @@ public sealed class ModuleEditorControl : UserControl
}
}
- private void AddModule()
+ private async Task RunModuleCommandAsync(Func command)
+ {
+ if (_moduleCommandInProgress)
+ {
+ return;
+ }
+
+ _moduleCommandInProgress = true;
+ SetEditorEnabled(_selectedModule is not null);
+ try
+ {
+ await command();
+ }
+ catch (Exception ex)
+ {
+ if (!IsDisposed)
+ {
+ MessageBox.Show(ex.Message, "模块操作失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ finally
+ {
+ _moduleCommandInProgress = false;
+ if (!IsDisposed)
+ {
+ SetEditorEnabled(_selectedModule is not null);
+ }
+ }
+ }
+
+ private async Task AddModuleAsync()
{
var module = ModuleDefinition.CreateDefault(_moduleStore.CreateNextModuleName());
try
@@ -2726,10 +2759,10 @@ public sealed class ModuleEditorControl : UserControl
_moduleList.SelectedIndex = index;
}
- _runtimeRestartRequested();
+ await _runtimeRestartRequested();
}
- private void SaveSelectedModule()
+ private async Task SaveSelectedModuleAsync()
{
if (_selectedModule is null)
{
@@ -2759,10 +2792,10 @@ public sealed class ModuleEditorControl : UserControl
_moduleList.SelectedIndex = index;
}
- _runtimeRestartRequested();
+ await _runtimeRestartRequested();
}
- private void DeleteSelectedModule()
+ private async Task DeleteSelectedModuleAsync()
{
if (_selectedModule is null)
{
@@ -2781,7 +2814,7 @@ public sealed class ModuleEditorControl : UserControl
_moduleStore.Delete(_selectedModule);
LoadModules();
- _runtimeRestartRequested();
+ await _runtimeRestartRequested();
}
private bool TryReadModule(out ModuleDefinition module)