diff --git a/Daybreak.Injector/NativeMethods.cs b/Daybreak.Injector/NativeMethods.cs
index c07bada0..97d8e812 100644
--- a/Daybreak.Injector/NativeMethods.cs
+++ b/Daybreak.Injector/NativeMethods.cs
@@ -25,6 +25,10 @@ internal static partial class NativeMethods
[LibraryImport("kernel32.dll")]
public static partial nint OpenProcess(ProcessAccessFlags dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessID);
+ [LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static partial bool QueryFullProcessImageName(nint hProcess, uint dwFlags, [Out] char[] lpExeName, ref uint lpdwSize);
+
[LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", StringMarshalling = StringMarshalling.Utf16)]
public static partial nint GetModuleHandle(string lpModuleName);
diff --git a/Daybreak.Injector/ProcessResolver.cs b/Daybreak.Injector/ProcessResolver.cs
new file mode 100644
index 00000000..68eafb3e
--- /dev/null
+++ b/Daybreak.Injector/ProcessResolver.cs
@@ -0,0 +1,78 @@
+using Daybreak.Shared.Models;
+using System.Diagnostics;
+
+namespace Daybreak.Injector;
+
+///
+/// Resolves a Wine-internal process id from a Guild Wars executable path.
+///
+/// On Linux multiple Guild Wars instances (each in its own install directory) run under
+/// the same Wine prefix. Matching by executable name alone cannot tell them apart, so we
+/// enumerate the running processes and compare each one's full image path — which the
+/// caller already knows from the Linux side — to find the unique owning Wine process.
+///
+/// This runs inside Wine, where is the Wine pid, so the matched
+/// id is exactly what the stub/winapi injectors expect.
+///
+public static class ProcessResolver
+{
+ private const string GuildWarsProcessName = "Gw";
+
+ public static InjectorResponses.ResolveResult Resolve(string executablePath, out int processId)
+ {
+ processId = 0;
+ var target = NormalizePath(executablePath);
+
+ foreach (var process in Process.GetProcessesByName(GuildWarsProcessName))
+ {
+ try
+ {
+ if (TryGetImagePath(process.Id) is { } imagePath &&
+ string.Equals(NormalizePath(imagePath), target, StringComparison.Ordinal))
+ {
+ processId = process.Id;
+ return InjectorResponses.ResolveResult.Success;
+ }
+ }
+ finally
+ {
+ process.Dispose();
+ }
+ }
+
+ return InjectorResponses.ResolveResult.ProcessNotFound;
+ }
+
+ private static string? TryGetImagePath(int processId)
+ {
+ var handle = NativeMethods.OpenProcess(
+ NativeMethods.ProcessAccessFlags.QueryLimitedInformation, false, (uint)processId);
+ if (handle is 0)
+ {
+ return null;
+ }
+
+ try
+ {
+ var buffer = new char[1024];
+ var size = (uint)buffer.Length;
+ if (!NativeMethods.QueryFullProcessImageName(handle, 0, buffer, ref size) || size is 0)
+ {
+ return null;
+ }
+
+ return new string(buffer, 0, (int)size);
+ }
+ finally
+ {
+ NativeMethods.CloseHandle(handle);
+ }
+ }
+
+ ///
+ /// Normalizes a Windows/Wine path for comparison: unifies separators, trims a trailing
+ /// null/whitespace, and lower-cases (Windows paths are case-insensitive).
+ ///
+ private static string NormalizePath(string path) =>
+ path.Trim().TrimEnd('\0').Replace('/', '\\').ToLowerInvariant();
+}
diff --git a/Daybreak.Injector/Program.cs b/Daybreak.Injector/Program.cs
index e1a78d9b..f7bb7dcf 100644
--- a/Daybreak.Injector/Program.cs
+++ b/Daybreak.Injector/Program.cs
@@ -12,11 +12,13 @@ static void PrintUsage()
Console.WriteLine("- stub");
Console.WriteLine("- launch");
Console.WriteLine("- resume");
+ Console.WriteLine("- resolve");
Console.WriteLine("Examples:");
Console.WriteLine("1) Daybreak.Injector winapi 1234 C:\\path\\to\\dll.dll");
Console.WriteLine("2) Daybreak.Injector stub 1234 entryPoint C:\\path\\to\\dll.dll");
Console.WriteLine("3) Daybreak.Injector launch true C:\\path\\to\\dll.dll arg1 arg2 arg3");
Console.WriteLine("4) Daybreak.Injector resume 1234");
+ Console.WriteLine("5) Daybreak.Injector resolve \"Z:\\path\\to\\Gw.exe\"");
Console.WriteLine("======================================================");
}
@@ -173,6 +175,24 @@ static bool TryParseThreadResumeArgs(
return true;
}
+static bool TryParseResolveArgs(
+ string[] args,
+ [NotNullWhen(true)] out string? executablePath,
+ out InjectorResponses.ResolveResult exitCode)
+{
+ executablePath = default;
+ if (args.Length < 2 || string.IsNullOrWhiteSpace(args[1]))
+ {
+ PrintUsage();
+ exitCode = InjectorResponses.ResolveResult.InvalidArgs;
+ return false;
+ }
+
+ executablePath = args[1];
+ exitCode = InjectorResponses.ResolveResult.Success;
+ return true;
+}
+
if (args.Length < 1)
{
PrintUsage();
@@ -244,6 +264,24 @@ switch (mode)
Console.WriteLine($"ExitCode: {result}");
return result;
}
+ case "resolve":
+ {
+ if (!TryParseResolveArgs(args, out var executablePath, out var parseResult))
+ {
+ Console.WriteLine($"ExitCode: {(int)parseResult}");
+ return (int)parseResult;
+ }
+
+ Console.WriteLine($"Resolving Wine PID for {executablePath}");
+ var result = ProcessResolver.Resolve(executablePath, out var processId);
+ if (result is InjectorResponses.ResolveResult.Success)
+ {
+ Console.WriteLine($"ProcessId: {processId}");
+ }
+
+ Console.WriteLine($"ExitCode: {(int)result}");
+ return (int)result;
+ }
default:
PrintUsage();
Console.WriteLine($"ExitCode: {(int)InjectorResponses.GenericResults.InvalidMode}");
diff --git a/Daybreak.Linux/Services/Injection/DaybreakInjector.cs b/Daybreak.Linux/Services/Injection/DaybreakInjector.cs
index d151899f..fc2ec1c4 100644
--- a/Daybreak.Linux/Services/Injection/DaybreakInjector.cs
+++ b/Daybreak.Linux/Services/Injection/DaybreakInjector.cs
@@ -18,6 +18,7 @@ public class DaybreakInjector(
) : IDaybreakInjector
{
private const string InjectorRelativePath = "Injector/Daybreak.Injector.exe";
+ private const string GuildWarsExecutableName = "Gw.exe";
private readonly ILogger logger = logger;
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
@@ -57,8 +58,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector;
}
- // Convert Linux PID to Wine PID for the injector
- var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
+ // Resolve the Wine PID by matching the process's full image path (handles
+ // multiple concurrent Guild Wars instances).
+ var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -99,8 +101,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector;
}
- // Convert Linux PID to Wine PID for the injector
- var winePid = this.winePidMapper.LinuxPidToWinePid(processId);
+ // Resolve the Wine PID by matching the process's full image path (handles
+ // multiple concurrent Guild Wars instances).
+ var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null)
{
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -290,4 +293,88 @@ public class DaybreakInjector(
return defaultExitCode;
}
+
+ ///
+ /// Resolves the Wine PID for a Linux Guild Wars process by asking the injector (which runs
+ /// inside Wine) to match on the process's full image path. This disambiguates multiple
+ /// concurrent Guild Wars instances, which a name-only lookup cannot.
+ ///
+ private async Task ResolveWinePid(int linuxPid, CancellationToken cancellationToken)
+ {
+ var scopedLogger = this.logger.CreateScopedLogger();
+
+ var wineExecutablePath = TryGetWineExecutablePath(linuxPid);
+ if (wineExecutablePath is null)
+ {
+ scopedLogger.LogError("Could not read Wine executable path for Linux PID {ProcessId}", linuxPid);
+ return null;
+ }
+
+ var (output, _, _) = await this.LaunchInjector(
+ ["resolve", $"\"{wineExecutablePath}\""],
+ cancellationToken,
+ completionChecker: (line, _) => line.StartsWith("ExitCode: ")
+ );
+
+ var winePid = ParseLabeledIntFromOutput(output, "ProcessId: ");
+ if (winePid is null)
+ {
+ scopedLogger.LogWarning(
+ "Injector could not resolve Wine PID for {ExecutablePath} (Linux PID {ProcessId})",
+ wineExecutablePath,
+ linuxPid
+ );
+ return null;
+ }
+
+ scopedLogger.LogDebug(
+ "Resolved Linux PID {ProcessId} -> Wine PID {WinePid} via {ExecutablePath}",
+ linuxPid,
+ winePid.Value,
+ wineExecutablePath
+ );
+ return winePid;
+ }
+
+ ///
+ /// Reads the Wine-form executable path (e.g. "Z:\home\...\Gw.exe") from the process's
+ /// command line, used to uniquely identify it among concurrent Guild Wars instances.
+ ///
+ private static string? TryGetWineExecutablePath(int linuxPid)
+ {
+ try
+ {
+ var cmdline = File.ReadAllText($"/proc/{linuxPid}/cmdline");
+ var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
+ return segments.FirstOrDefault(s =>
+ s.EndsWith(GuildWarsExecutableName, StringComparison.OrdinalIgnoreCase));
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static int? ParseLabeledIntFromOutput(string? output, string label)
+ {
+ if (output is null)
+ {
+ return null;
+ }
+
+ foreach (var line in output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (line.StartsWith(label) &&
+ int.TryParse(line[label.Length..], out var value))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
}
diff --git a/Daybreak.Linux/Services/Wine/IWinePidMapper.cs b/Daybreak.Linux/Services/Wine/IWinePidMapper.cs
index ed089831..a6cd2e01 100644
--- a/Daybreak.Linux/Services/Wine/IWinePidMapper.cs
+++ b/Daybreak.Linux/Services/Wine/IWinePidMapper.cs
@@ -1,8 +1,9 @@
namespace Daybreak.Linux.Services.Wine;
///
-/// Stateless translator between Wine-internal PIDs and Linux system PIDs.
-/// Uses /proc scanning and winedbg to resolve mappings on demand.
+/// Translates Wine-internal PIDs to Linux system PIDs by scanning /proc.
+/// The reverse direction (Linux → Wine PID) is handled by the injector, which runs
+/// inside Wine and can disambiguate concurrent instances by full image path.
///
public interface IWinePidMapper
{
@@ -18,13 +19,4 @@ public interface IWinePidMapper
/// unambiguous matching) or a bare file name (e.g. "Gw.exe") as a best-effort fallback.
/// The Linux PID, or null if not found.
int? WinePidToLinuxPid(int winePid, string executable);
-
- ///
- /// Converts a Linux system PID back to a Wine-internal PID.
- /// Reads the process's cmdline to determine the executable, then queries
- /// winedbg to find the corresponding Wine PID.
- ///
- /// The Linux system PID.
- /// The Wine PID, or null if not found.
- int? LinuxPidToWinePid(int linuxPid);
}
diff --git a/Daybreak.Linux/Services/Wine/WinePidMapper.cs b/Daybreak.Linux/Services/Wine/WinePidMapper.cs
index 9e3b3b98..ec5ae2da 100644
--- a/Daybreak.Linux/Services/Wine/WinePidMapper.cs
+++ b/Daybreak.Linux/Services/Wine/WinePidMapper.cs
@@ -1,19 +1,16 @@
-using System.Diagnostics;
-using System.Globalization;
using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Wine;
///
-/// Stateless translator between Wine-internal PIDs and Linux system PIDs.
-/// Uses /proc scanning for Wine→Linux and winedbg for Linux→Wine.
+/// Translates Wine-internal PIDs to Linux system PIDs by scanning /proc and matching the
+/// full executable path. The reverse direction (Linux → Wine PID) lives in the injector,
+/// which runs inside Wine and can disambiguate concurrent instances by image path.
///
public sealed class WinePidMapper(
- IWinePrefixManager winePrefixManager,
ILogger logger
) : IWinePidMapper
{
- private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly ILogger logger = logger;
///
@@ -113,113 +110,4 @@ public sealed class WinePidMapper(
return winePath.Replace('\\', '/');
}
-
- ///
- public int? LinuxPidToWinePid(int linuxPid)
- {
- // Step 1: Read the executable name from /proc//cmdline
- string? executableName;
- try
- {
- var cmdline = File.ReadAllText($"/proc/{linuxPid}/cmdline");
- // cmdline is null-separated; find the segment containing .exe
- var segments = cmdline.Split('\0', StringSplitOptions.RemoveEmptyEntries);
- var exeSegment = segments.FirstOrDefault(s => s.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
- ?? segments.FirstOrDefault(s => s.Contains(".exe", StringComparison.OrdinalIgnoreCase));
-
- if (exeSegment is null)
- {
- this.logger.LogWarning("Could not determine executable name for Linux PID {LinuxPid}", linuxPid);
- return null;
- }
-
- // Extract just the filename (e.g. "Gw.exe" from "Z:\mnt\...\Gw.exe")
- executableName = exeSegment.Split('\\').Last().Split('/').Last();
- }
- catch (Exception ex)
- {
- this.logger.LogWarning(ex, "Could not read cmdline for Linux PID {LinuxPid}", linuxPid);
- return null;
- }
-
- // Step 2: Query winedbg for the Wine PID of that executable
- return this.QueryWineDbgForPid(executableName);
- }
-
- ///
- /// Runs winedbg --command "info proc" in our prefix and parses the output
- /// to find the Wine PID for the given executable name.
- /// Output format: " 0000021c 1 'Gw.exe'"
- ///
- private int? QueryWineDbgForPid(string executableName)
- {
- var prefixPath = this.winePrefixManager.GetWinePrefixPath();
-
- try
- {
- var startInfo = new ProcessStartInfo
- {
- FileName = "winedbg",
- Arguments = "--command \"info proc\"",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- };
-
- startInfo.Environment["WINEPREFIX"] = prefixPath;
-
- using var process = new Process { StartInfo = startInfo };
- process.Start();
-
- var output = process.StandardOutput.ReadToEnd();
- process.WaitForExit(TimeSpan.FromSeconds(5));
-
- // Parse lines like:
- // 0000021c 1 'Gw.exe'
- // 00000038 12 \_ 'services.exe'
- foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
- {
- var trimmed = line.Trim();
-
- // Skip header line
- if (trimmed.StartsWith("pid", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- // Check if this line contains our executable
- if (!trimmed.Contains(executableName, StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- // Strip tree prefixes like "\_ " or "= "
- var cleaned = trimmed.TrimStart('=', ' ');
- if (cleaned.StartsWith("\\_"))
- {
- cleaned = cleaned[2..].TrimStart();
- }
-
- // First token is the hex PID
- var hexPid = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
- if (hexPid is not null && int.TryParse(hexPid, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var winePid))
- {
- this.logger.LogDebug(
- "Resolved Linux PID -> Wine PID {WinePid} (0x{WinePidHex}) for {ExeName}",
- winePid, hexPid, executableName
- );
-
- return winePid;
- }
- }
- }
- catch (Exception ex)
- {
- this.logger.LogError(ex, "Failed to query winedbg for {ExeName}", executableName);
- }
-
- this.logger.LogWarning("Could not find Wine PID for {ExeName} via winedbg", executableName);
- return null;
- }
}
diff --git a/Daybreak.Shared/Models/InjectorResponses.cs b/Daybreak.Shared/Models/InjectorResponses.cs
index 65b72d49..a1367216 100644
--- a/Daybreak.Shared/Models/InjectorResponses.cs
+++ b/Daybreak.Shared/Models/InjectorResponses.cs
@@ -52,4 +52,12 @@ public static class InjectorResponses
InvalidInjector = -3,
InvalidThreadHandle = -400,
}
+
+ public enum ResolveResult
+ {
+ Success = 0,
+ InvalidArgs = -1,
+ InvalidInjector = -3,
+ ProcessNotFound = -501,
+ }
}