feat: 引入 WowProcessLocator 以支持动态进程窗口查找,移除窗口标题参数,更新相关逻辑

This commit is contained in:
waynebian01
2026-08-09 21:43:14 +08:00
parent a4f30f7349
commit 19569a5abd
15 changed files with 237 additions and 97 deletions

View File

@@ -8,7 +8,6 @@ public enum SendMode
}
public sealed record AppOptions(
string WindowTitle,
string ToggleKey,
SendMode Mode,
string? ModuleId,
@@ -17,7 +16,6 @@ public sealed record AppOptions(
{
public static AppOptions FromArgs(string[] args)
{
var windowTitle = "魔兽世界";
var toggleKey = "XBUTTON2";
var mode = SendMode.Switch;
string? moduleId = null;
@@ -30,10 +28,6 @@ public sealed record AppOptions(
var value = i + 1 < args.Length ? args[i + 1] : null;
switch (arg)
{
case "--window" when value is not null:
windowTitle = value;
i++;
break;
case "--toggle" when value is not null:
toggleKey = value;
i++;
@@ -57,7 +51,7 @@ public sealed record AppOptions(
}
}
return new AppOptions(windowTitle, toggleKey, mode, moduleId, logicInterval, renderInterval);
return new AppOptions(toggleKey, mode, moduleId, logicInterval, renderInterval);
}
private static SendMode ParseMode(string value)

View File

@@ -32,7 +32,8 @@ internal static class Program
var baseDirectory = AppPaths.BaseDirectory;
var moduleStore = new ModuleStore(ModuleStore.ResolveModuleDirectory(baseDirectory));
var triggerKeyState = new WindowsTriggerKeyState();
var runtimeFactory = new ShigureRuntimeFactory(baseDirectory, moduleStore, triggerKeyState);
var processLocator = new WowProcessLocator(baseDirectory);
var runtimeFactory = new ShigureRuntimeFactory(baseDirectory, moduleStore, triggerKeyState, processLocator);
var runtimeSession = new RuntimeSessionCoordinator(runtimeFactory);
Application.Run(new MainForm(
@@ -40,6 +41,7 @@ internal static class Program
baseDirectory,
moduleStore,
triggerKeyState,
processLocator,
runtimeSession));
}
}

View File

@@ -10,17 +10,20 @@ internal sealed class ShigureRuntimeFactory : IShigureRuntimeFactory
private readonly string _baseDirectory;
private readonly ModuleStore _moduleStore;
private readonly ITriggerKeyState _triggerKeyState;
private readonly WowProcessLocator _processLocator;
private readonly TimeProvider _timeProvider;
public ShigureRuntimeFactory(
string baseDirectory,
ModuleStore moduleStore,
ITriggerKeyState triggerKeyState,
WowProcessLocator processLocator,
TimeProvider? timeProvider = null)
{
_baseDirectory = baseDirectory;
_moduleStore = moduleStore;
_triggerKeyState = triggerKeyState;
_processLocator = processLocator;
_timeProvider = timeProvider ?? TimeProvider.System;
}
@@ -32,9 +35,9 @@ internal sealed class ShigureRuntimeFactory : IShigureRuntimeFactory
return new ShigureRuntime(
options,
new PixelScanner(options.WindowTitle),
new PixelScanner(_processLocator),
new StateBuilder(config),
new KeySender(options.WindowTitle),
new KeySender(_processLocator),
_triggerKeyState,
new LogicRegistry(keymap, _moduleStore, options.ModuleId),
_timeProvider);

View File

@@ -13,12 +13,12 @@ Shigure 是一个 **Windows WinForms.NET 10** 桌面程序:扫描目标
```powershell
dotnet build .\Shigure.csproj
dotnet run --project .\Shigure.csproj
dotnet run --project .\Shigure.csproj -- --window 魔兽世界 --toggle XBUTTON2 --mode switch --logic-ms 100 --render-ms 100
dotnet run --project .\Shigure.csproj -- --toggle XBUTTON2 --mode switch --logic-ms 100 --render-ms 100
```
- 目标框架 `net10.0-windows``WinExe``Nullable`/`ImplicitUsings` 均 enable。
- **没有测试项目**:验证 = 能编译 + 实际运行点开「设置」走查。`dotnet build` 干净通过0 警告 0 错误)是基线要求。
- 启动参数见 [README.md](README.md#运行)`--window/--toggle/--mode/--logic-ms/--render-ms`),解析在 [App/AppOptions.cs](App/AppOptions.cs)。
- 启动参数见 [README.md](README.md#运行)`--toggle/--mode/--logic-ms/--render-ms`),解析在 [App/AppOptions.cs](App/AppOptions.cs)。目标进程名来自 `wow_process.txt`
### ⚠️ 随机重启会拦截普通运行
@@ -74,7 +74,7 @@ config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .c
### 定位
[Infrastructure/WowAddonLocator.cs](Infrastructure/WowAddonLocator.cs) 通过 `FindWindow`进程路径向上查找 `Interface\AddOns\Fuyutsui`。提供三个入口:`FindAddonRoot``FindClassDirectory``class/` 子目录)、`FindClassMacrosPath``core/classmacros.lua`)。
[Infrastructure/WowProcessLocator.cs](Infrastructure/WowProcessLocator.cs) 读取 `wow_process.txt`,按 Windows Z 顺序选择最靠前的候选进程可见顶层窗口;[Infrastructure/WowAddonLocator.cs](Infrastructure/WowAddonLocator.cs) 再由该进程路径向上查找 `Interface\AddOns\Fuyutsui`。提供三个入口:`FindAddonRoot``FindClassDirectory``class/` 子目录)、`FindClassMacrosPath``core/classmacros.lua`)。
### Lua 解析

View File

@@ -1,5 +1,3 @@
using System.Text;
namespace Shigure;
/// <summary>
@@ -11,9 +9,9 @@ internal static class WowAddonLocator
private const string ClassRelativePath = @"Interface\AddOns\Fuyutsui\class";
private const string ClassMacrosRelativePath = @"Interface\AddOns\Fuyutsui\core\classmacros.lua";
public static string? FindClassDirectory(string windowTitle)
public static string? FindClassDirectory(WowProcessLocator processLocator)
{
var addonRoot = FindAddonRoot(windowTitle);
var addonRoot = FindAddonRoot(processLocator);
if (addonRoot is null)
{
return null;
@@ -24,9 +22,9 @@ internal static class WowAddonLocator
}
/// <summary>定位 Fuyutsui 插件根目录(含 class/、core/)。</summary>
public static string? FindAddonRoot(string windowTitle)
public static string? FindAddonRoot(WowProcessLocator processLocator)
{
var exePath = TryGetProcessPathByWindowTitle(windowTitle);
var exePath = processLocator.FindFrontmostProcessPath();
if (string.IsNullOrWhiteSpace(exePath))
{
return null;
@@ -54,9 +52,9 @@ internal static class WowAddonLocator
return null;
}
public static string? FindClassMacrosPath(string windowTitle)
public static string? FindClassMacrosPath(WowProcessLocator processLocator)
{
var addonRoot = FindAddonRoot(windowTitle);
var addonRoot = FindAddonRoot(processLocator);
if (addonRoot is not null)
{
var fromRoot = Path.Combine(addonRoot, "core", "classmacros.lua");
@@ -66,7 +64,7 @@ internal static class WowAddonLocator
}
}
var exePath = TryGetProcessPathByWindowTitle(windowTitle);
var exePath = processLocator.FindFrontmostProcessPath();
if (string.IsNullOrWhiteSpace(exePath))
{
return null;
@@ -86,46 +84,4 @@ internal static class WowAddonLocator
return null;
}
private static string? TryGetProcessPathByWindowTitle(string windowTitle)
{
if (string.IsNullOrWhiteSpace(windowTitle))
{
return null;
}
var hwnd = NativeMethods.FindWindow(null, windowTitle.Trim());
if (hwnd == 0)
{
return null;
}
_ = NativeMethods.GetWindowThreadProcessId(hwnd, out var processId);
if (processId == 0)
{
return null;
}
var handle = NativeMethods.OpenProcess(NativeMethods.ProcessQueryLimitedInformation, false, processId);
if (handle == 0)
{
return null;
}
try
{
var buffer = new StringBuilder(1024);
var size = buffer.Capacity;
if (!NativeMethods.QueryFullProcessImageName(handle, 0, buffer, ref size) || size <= 0)
{
return null;
}
return buffer.ToString();
}
finally
{
_ = NativeMethods.CloseHandle(handle);
}
}
}

View File

@@ -0,0 +1,158 @@
using System.Diagnostics;
using System.Text;
namespace Shigure;
/// <summary>
/// 按 wow_process.txt 中的进程名,从 Windows Z 顺序顶部查找第一个可见顶层窗口。
/// 每次查询都会重新读取配置与窗口顺序,以便运行期间直接切换游戏窗口或修改进程名。
/// </summary>
internal sealed class WowProcessLocator
{
private const string ProcessFileName = "wow_process.txt";
private readonly string _processFilePath;
public WowProcessLocator(string baseDirectory)
{
_processFilePath = Path.Combine(baseDirectory, ProcessFileName);
}
public string ProcessFilePath => _processFilePath;
public nint FindFrontmostWindow()
{
var processIds = GetCandidateProcessIds();
if (processIds.Count == 0)
{
return 0;
}
nint foundWindow = 0;
_ = NativeMethods.EnumWindows((hwnd, lParam) =>
{
if (!NativeMethods.IsWindowVisible(hwnd))
{
return true;
}
_ = NativeMethods.GetWindowThreadProcessId(hwnd, out var processId);
if (!processIds.Contains(processId))
{
return true;
}
foundWindow = hwnd;
return false;
}, 0);
return foundWindow;
}
public string? FindFrontmostProcessPath()
{
var hwnd = FindFrontmostWindow();
if (hwnd == 0)
{
return null;
}
_ = NativeMethods.GetWindowThreadProcessId(hwnd, out var processId);
return processId == 0 ? null : TryGetProcessPath(processId);
}
public string DescribeConfiguredProcesses()
{
var names = ReadProcessNames();
return names.Count == 0 ? "未配置" : string.Join("、", names);
}
private HashSet<uint> GetCandidateProcessIds()
{
var result = new HashSet<uint>();
foreach (var processName in ReadProcessNames())
{
Process[] processes;
try
{
processes = Process.GetProcessesByName(processName);
}
catch (InvalidOperationException)
{
continue;
}
foreach (var process in processes)
{
using (process)
{
try
{
result.Add(unchecked((uint)process.Id));
}
catch (InvalidOperationException)
{
// 进程可能在枚举期间退出。
}
}
}
}
return result;
}
private IReadOnlyList<string> ReadProcessNames()
{
try
{
return File.ReadLines(_processFilePath)
.Select(NormalizeProcessName)
.Where(name => name is not null)
.Select(name => name!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
catch (IOException)
{
return [];
}
catch (UnauthorizedAccessException)
{
return [];
}
}
private static string? NormalizeProcessName(string line)
{
var name = line.Trim();
if (name.Length == 0 || name.StartsWith('#') || name.StartsWith(';'))
{
return null;
}
return name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
? name[..^4].Trim()
: name;
}
private static string? TryGetProcessPath(uint processId)
{
var handle = NativeMethods.OpenProcess(NativeMethods.ProcessQueryLimitedInformation, false, processId);
if (handle == 0)
{
return null;
}
try
{
var buffer = new StringBuilder(1024);
var size = buffer.Capacity;
return NativeMethods.QueryFullProcessImageName(handle, 0, buffer, ref size) && size > 0
? buffer.ToString()
: null;
}
finally
{
_ = NativeMethods.CloseHandle(handle);
}
}
}

View File

@@ -2,27 +2,28 @@
public sealed class KeySender : IRuntimeKeyOutput
{
private readonly string _windowTitle;
private readonly WowProcessLocator _processLocator;
public KeySender(string windowTitle)
internal KeySender(WowProcessLocator processLocator)
{
_windowTitle = windowTitle;
_processLocator = processLocator;
}
public string? LastFailureReason { get; private set; }
public bool Send(string hotkey)
{
var result = SendCore(hotkey);
var result = SendCore(hotkey, expectedWindow: 0);
LastFailureReason = result.FailureReason;
return result.Succeeded;
}
public static int? GetVk(string keyName) => WindowsVirtualKeyMap.Resolve(keyName);
KeySendResult IRuntimeKeyOutput.Send(string hotkey) => SendCore(hotkey);
KeySendResult IRuntimeKeyOutput.Send(string hotkey, nint expectedWindow)
=> SendCore(hotkey, expectedWindow);
private KeySendResult SendCore(string hotkey)
private KeySendResult SendCore(string hotkey, nint expectedWindow)
{
var (mods, mainKey) = ParseHotkey(hotkey);
if (mainKey is null)
@@ -36,10 +37,15 @@ public sealed class KeySender : IRuntimeKeyOutput
return Fail($"无法识别主键“{mainKey}”");
}
var hwnd = NativeMethods.FindWindow(null, _windowTitle);
var hwnd = _processLocator.FindFrontmostWindow();
if (hwnd == 0)
{
return Fail($"未找到目标窗口“{_windowTitle}”");
return Fail($"未找到目标进程的可见窗口wow_process.txt: {_processLocator.DescribeConfiguredProcesses()}");
}
if (expectedWindow != 0 && hwnd != expectedWindow)
{
return Fail("目标窗口已切换,等待重新扫描后再发送按键");
}
// ParseHotkey 只产出去重后的 CTRL/ALT/SHIFT, 三者都在虚拟键表里且映射到互异 VK,

View File

@@ -5,6 +5,8 @@ namespace Shigure;
internal static class NativeMethods
{
public delegate bool EnumWindowsProc(nint hWnd, nint lParam);
public const uint ProcessQueryLimitedInformation = 0x1000;
public const uint WmKeyDown = 0x0100;
@@ -47,8 +49,13 @@ internal static class NativeMethods
public int Bottom;
}
[DllImport("user32.dll", EntryPoint = "FindWindowW", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern nint FindWindow(string? lpClassName, string lpWindowName);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindowVisible(nint hWnd);
[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId);

View File

@@ -73,10 +73,10 @@ dotnet run --project .\Shigure.csproj
可选启动参数:
```powershell
dotnet run --project .\Shigure.csproj -- --window 魔兽世界 --toggle XBUTTON2 --mode switch --logic-ms 100 --render-ms 100
dotnet run --project .\Shigure.csproj -- --toggle XBUTTON2 --mode switch --logic-ms 100 --render-ms 100
```
- `--window`:目标窗口标题,默认 `魔兽世界`
- `wow_process.txt`:每行一个目标进程名(可带或不带 `.exe`)。程序使用 Windows Z 顺序中最靠前的候选进程可见顶层窗口,切换窗口后会自动跟随
- `--toggle`:触发键,默认 `XBUTTON2`
- `--mode`:发送模式,支持 `switch``click``hold`
- `--logic-ms`:逻辑循环间隔,默认 `100` ms最小 `50` ms。

View File

@@ -8,7 +8,10 @@ public sealed record ScreenScanResult(
IReadOnlyDictionary<int, int>? RowData,
IReadOnlyDictionary<int, int> BarData,
IReadOnlyDictionary<int, int> HealAbsorbData,
string? FailureReason);
string? FailureReason)
{
internal nint TargetWindowHandle { get; init; }
}
public sealed class PixelScanner : IRuntimeScreenScanner
{
@@ -16,11 +19,11 @@ public sealed class PixelScanner : IRuntimeScreenScanner
private const int TopRowFirstSchemeMax = 255;
private const int HealAbsorbMaxRows = 6;
private const int HealAbsorbMaxUnits = 30;
private readonly string _windowTitle;
private readonly WowProcessLocator _processLocator;
public PixelScanner(string windowTitle)
internal PixelScanner(WowProcessLocator processLocator)
{
_windowTitle = windowTitle;
_processLocator = processLocator;
try
{
NativeMethods.SetProcessDPIAware();
@@ -35,15 +38,19 @@ public sealed class PixelScanner : IRuntimeScreenScanner
{
var emptyBars = new Dictionary<int, int>();
var emptyAbsorb = new Dictionary<int, int>();
var hwnd = NativeMethods.FindWindow(null, _windowTitle);
var hwnd = _processLocator.FindFrontmostWindow();
if (hwnd == 0)
{
return new ScreenScanResult(null, emptyBars, emptyAbsorb, $"未找到目标窗口“{_windowTitle}”");
return new ScreenScanResult(
null,
emptyBars,
emptyAbsorb,
$"未找到目标进程的可见窗口wow_process.txt: {_processLocator.DescribeConfiguredProcesses()}");
}
if (NativeMethods.IsIconic(hwnd))
{
return new ScreenScanResult(null, emptyBars, emptyAbsorb, $"目标窗口“{_windowTitle}”已最小化");
return new ScreenScanResult(null, emptyBars, emptyAbsorb, "最靠前的目标进程窗口已最小化");
}
var point = new NativeMethods.Point(0, 0);
@@ -82,13 +89,14 @@ public sealed class PixelScanner : IRuntimeScreenScanner
var healAbsorbData = markerY is null
? emptyAbsorb
: ScanHealAbsorbGrid(point.X, point.Y, width, height, markerY.Value);
return rowData.Count == 0
var result = rowData.Count == 0
? new ScreenScanResult(null, barData, healAbsorbData, "未找到有效的状态像素起始标记")
: new ScreenScanResult(
rowData,
barData,
healAbsorbData,
markerY is null ? "未找到 CountBars 标记,层数条和治疗吸收数据未采集" : null);
return result with { TargetWindowHandle = hwnd };
}
catch (Exception ex)
{

View File

@@ -36,7 +36,7 @@ public sealed record LogicEvaluation(string? ModuleName, LogicDecision? Decision
internal interface IRuntimeKeyOutput
{
KeySendResult Send(string hotkey);
KeySendResult Send(string hotkey, nint expectedWindow);
}
public readonly record struct KeySendResult(bool Succeeded, string? FailureReason)

View File

@@ -253,7 +253,7 @@ public sealed class ShigureRuntime
var sendAttemptAt = _timeProvider.GetUtcNow();
if (CanSend(decision, sendAttemptAt))
{
SendAndPauseLogic(decision);
SendAndPauseLogic(decision, scan.TargetWindowHandle);
}
}
@@ -267,14 +267,14 @@ public sealed class ShigureRuntime
var sendAttemptAt = _timeProvider.GetUtcNow();
if (CanSend(decision, sendAttemptAt))
{
SendAndPauseLogic(decision);
SendAndPauseLogic(decision, scan.TargetWindowHandle);
}
}
}
private void SendAndPauseLogic(LogicDecision decision)
private void SendAndPauseLogic(LogicDecision decision, nint targetWindowHandle)
{
var sendResult = _keySender.Send(decision.Hotkey!);
var sendResult = _keySender.Send(decision.Hotkey!, targetWindowHandle);
if (!sendResult.Succeeded)
{
var info = _unitInfo.ToDictionary(

View File

@@ -24,6 +24,7 @@
<None Include="config\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="keymap\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="module\**\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="wow_process.txt" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -51,6 +51,7 @@ public sealed class MainForm : Form, IMessageFilter
private readonly string _baseDirectory;
private readonly ModuleStore _moduleStore;
private readonly ITriggerKeyState _triggerKeyState;
private readonly WowProcessLocator _processLocator;
private readonly RuntimeSessionCoordinator _runtimeSession;
private readonly ModuleEditorControl _moduleEditor;
private readonly ClassConfigEditorControl _classConfigEditor;
@@ -76,12 +77,14 @@ public sealed class MainForm : Form, IMessageFilter
string baseDirectory,
ModuleStore moduleStore,
ITriggerKeyState triggerKeyState,
WowProcessLocator processLocator,
RuntimeSessionCoordinator runtimeSession)
{
_initialOptions = initialOptions;
_baseDirectory = baseDirectory;
_moduleStore = moduleStore;
_triggerKeyState = triggerKeyState;
_processLocator = processLocator;
_runtimeSession = runtimeSession;
_uiCache = UiCacheStore.Load();
_statusForm = new StatusForm();
@@ -103,11 +106,11 @@ public sealed class MainForm : Form, IMessageFilter
_moduleEditor = new ModuleEditorControl(_moduleStore, RestartRuntimeFromEditorAsync, _baseDirectory);
_statusForm.AttachModuleEditor(_moduleEditor);
_classConfigEditor = new ClassConfigEditorControl(
() => WowAddonLocator.FindClassDirectory(_initialOptions.WindowTitle),
() => WowAddonLocator.FindClassDirectory(_processLocator),
UpdateConfigFromAddonAsync);
_statusForm.AttachConfigEditor(_classConfigEditor);
_classMacrosEditor = new ClassMacrosEditorControl(
() => WowAddonLocator.FindClassMacrosPath(_initialOptions.WindowTitle),
() => WowAddonLocator.FindClassMacrosPath(_processLocator),
UpdateConfigFromAddonAsync);
_statusForm.AttachMacrosEditor(_classMacrosEditor);
_statusForm.FormClosing += (_, _) =>
@@ -672,14 +675,14 @@ public sealed class MainForm : Form, IMessageFilter
return;
}
var windowTitle = _initialOptions.WindowTitle;
var classDirectory = WowAddonLocator.FindClassDirectory(windowTitle);
var classMacrosPath = WowAddonLocator.FindClassMacrosPath(windowTitle);
var processNames = _processLocator.DescribeConfiguredProcesses();
var classDirectory = WowAddonLocator.FindClassDirectory(_processLocator);
var classMacrosPath = WowAddonLocator.FindClassMacrosPath(_processLocator);
if (string.IsNullOrWhiteSpace(classDirectory))
{
_configSourceLabel.Text = $"Fuyutsui class: 未找到(请先打开「{windowTitle}」窗口";
_configSourceLabel.Text = $"Fuyutsui class: 未找到(目标进程: {processNames}";
MessageBox.Show(
$"未找到「{windowTitle}」窗口下的 Interface\\AddOns\\Fuyutsui\\class 目录。\n请确认游戏已启动且已安装 Fuyutsui。",
$"未找到目标进程({processNames})对应的 Interface\\AddOns\\Fuyutsui\\class 目录。\n请确认游戏已启动且已安装 Fuyutsui。",
"更新配置",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
@@ -884,7 +887,7 @@ public sealed class MainForm : Form, IMessageFilter
ResetRuntimeLogState();
SetRuntimeControls(running: true);
AppendLog($"运行已{(restart ? "" : "")}: {options.WindowTitle} / {options.ToggleKey} / {ModeLabel(options.Mode)}");
AppendLog($"运行已{(restart ? "" : "")}: {_processLocator.DescribeConfiguredProcesses()} / {options.ToggleKey} / {ModeLabel(options.Mode)}");
return true;
}

2
wow_process.txt Normal file
View File

@@ -0,0 +1,2 @@
Wow
WowT