Improve exe matching on Linux (#1567)

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
This commit is contained in:
2026-06-30 12:58:49 +02:00
parent 0a0ec5cefc
commit fabb98f74e
7 changed files with 225 additions and 130 deletions
+4
View File
@@ -25,6 +25,10 @@ internal static partial class NativeMethods
[LibraryImport("kernel32.dll")] [LibraryImport("kernel32.dll")]
public static partial nint OpenProcess(ProcessAccessFlags dwDesiredAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, uint dwProcessID); 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)] [LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", StringMarshalling = StringMarshalling.Utf16)]
public static partial nint GetModuleHandle(string lpModuleName); public static partial nint GetModuleHandle(string lpModuleName);
+78
View File
@@ -0,0 +1,78 @@
using Daybreak.Shared.Models;
using System.Diagnostics;
namespace Daybreak.Injector;
/// <summary>
/// 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 <see cref="Process.Id"/> is the Wine pid, so the matched
/// id is exactly what the stub/winapi injectors expect.
/// </summary>
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);
}
}
/// <summary>
/// Normalizes a Windows/Wine path for comparison: unifies separators, trims a trailing
/// null/whitespace, and lower-cases (Windows paths are case-insensitive).
/// </summary>
private static string NormalizePath(string path) =>
path.Trim().TrimEnd('\0').Replace('/', '\\').ToLowerInvariant();
}
+38
View File
@@ -12,11 +12,13 @@ static void PrintUsage()
Console.WriteLine("- stub"); Console.WriteLine("- stub");
Console.WriteLine("- launch"); Console.WriteLine("- launch");
Console.WriteLine("- resume"); Console.WriteLine("- resume");
Console.WriteLine("- resolve");
Console.WriteLine("Examples:"); Console.WriteLine("Examples:");
Console.WriteLine("1) Daybreak.Injector winapi 1234 C:\\path\\to\\dll.dll"); 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("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("3) Daybreak.Injector launch true C:\\path\\to\\dll.dll arg1 arg2 arg3");
Console.WriteLine("4) Daybreak.Injector resume 1234"); Console.WriteLine("4) Daybreak.Injector resume 1234");
Console.WriteLine("5) Daybreak.Injector resolve \"Z:\\path\\to\\Gw.exe\"");
Console.WriteLine("======================================================"); Console.WriteLine("======================================================");
} }
@@ -173,6 +175,24 @@ static bool TryParseThreadResumeArgs(
return true; 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) if (args.Length < 1)
{ {
PrintUsage(); PrintUsage();
@@ -244,6 +264,24 @@ switch (mode)
Console.WriteLine($"ExitCode: {result}"); Console.WriteLine($"ExitCode: {result}");
return 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: default:
PrintUsage(); PrintUsage();
Console.WriteLine($"ExitCode: {(int)InjectorResponses.GenericResults.InvalidMode}"); Console.WriteLine($"ExitCode: {(int)InjectorResponses.GenericResults.InvalidMode}");
@@ -18,6 +18,7 @@ public class DaybreakInjector(
) : IDaybreakInjector ) : IDaybreakInjector
{ {
private const string InjectorRelativePath = "Injector/Daybreak.Injector.exe"; private const string InjectorRelativePath = "Injector/Daybreak.Injector.exe";
private const string GuildWarsExecutableName = "Gw.exe";
private readonly ILogger<DaybreakInjector> logger = logger; private readonly ILogger<DaybreakInjector> logger = logger;
private readonly IWinePrefixManager winePrefixManager = winePrefixManager; private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
@@ -57,8 +58,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector; return InjectorResponses.InjectResult.InvalidInjector;
} }
// Convert Linux PID to Wine PID for the injector // Resolve the Wine PID by matching the process's full image path (handles
var winePid = this.winePidMapper.LinuxPidToWinePid(processId); // multiple concurrent Guild Wars instances).
var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null) if (winePid is null)
{ {
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId); scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -99,8 +101,9 @@ public class DaybreakInjector(
return InjectorResponses.InjectResult.InvalidInjector; return InjectorResponses.InjectResult.InvalidInjector;
} }
// Convert Linux PID to Wine PID for the injector // Resolve the Wine PID by matching the process's full image path (handles
var winePid = this.winePidMapper.LinuxPidToWinePid(processId); // multiple concurrent Guild Wars instances).
var winePid = await this.ResolveWinePid(processId, cancellationToken);
if (winePid is null) if (winePid is null)
{ {
scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId); scopedLogger.LogError("No Wine PID mapping found for Linux PID {ProcessId}", processId);
@@ -290,4 +293,88 @@ public class DaybreakInjector(
return defaultExitCode; return defaultExitCode;
} }
/// <summary>
/// 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.
/// </summary>
private async Task<int?> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
} }
+3 -11
View File
@@ -1,8 +1,9 @@
namespace Daybreak.Linux.Services.Wine; namespace Daybreak.Linux.Services.Wine;
/// <summary> /// <summary>
/// Stateless translator between Wine-internal PIDs and Linux system PIDs. /// Translates Wine-internal PIDs to Linux system PIDs by scanning /proc.
/// Uses /proc scanning and winedbg to resolve mappings on demand. /// The reverse direction (Linux → Wine PID) is handled by the injector, which runs
/// inside Wine and can disambiguate concurrent instances by full image path.
/// </summary> /// </summary>
public interface IWinePidMapper 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.</param> /// unambiguous matching) or a bare file name (e.g. "Gw.exe") as a best-effort fallback.</param>
/// <returns>The Linux PID, or null if not found.</returns> /// <returns>The Linux PID, or null if not found.</returns>
int? WinePidToLinuxPid(int winePid, string executable); int? WinePidToLinuxPid(int winePid, string executable);
/// <summary>
/// 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.
/// </summary>
/// <param name="linuxPid">The Linux system PID.</param>
/// <returns>The Wine PID, or null if not found.</returns>
int? LinuxPidToWinePid(int linuxPid);
} }
+3 -115
View File
@@ -1,19 +1,16 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Daybreak.Linux.Services.Wine; namespace Daybreak.Linux.Services.Wine;
/// <summary> /// <summary>
/// Stateless translator between Wine-internal PIDs and Linux system PIDs. /// Translates Wine-internal PIDs to Linux system PIDs by scanning /proc and matching the
/// Uses /proc scanning for Wine→Linux and winedbg for Linux→Wine. /// 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.
/// </summary> /// </summary>
public sealed class WinePidMapper( public sealed class WinePidMapper(
IWinePrefixManager winePrefixManager,
ILogger<WinePidMapper> logger ILogger<WinePidMapper> logger
) : IWinePidMapper ) : IWinePidMapper
{ {
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
private readonly ILogger<WinePidMapper> logger = logger; private readonly ILogger<WinePidMapper> logger = logger;
/// <inheritdoc /> /// <inheritdoc />
@@ -113,113 +110,4 @@ public sealed class WinePidMapper(
return winePath.Replace('\\', '/'); return winePath.Replace('\\', '/');
} }
/// <inheritdoc />
public int? LinuxPidToWinePid(int linuxPid)
{
// Step 1: Read the executable name from /proc/<pid>/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);
}
/// <summary>
/// Runs <c>winedbg --command "info proc"</c> in our prefix and parses the output
/// to find the Wine PID for the given executable name.
/// Output format: " 0000021c 1 'Gw.exe'"
/// </summary>
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;
}
} }
@@ -52,4 +52,12 @@ public static class InjectorResponses
InvalidInjector = -3, InvalidInjector = -3,
InvalidThreadHandle = -400, InvalidThreadHandle = -400,
} }
public enum ResolveResult
{
Success = 0,
InvalidArgs = -1,
InvalidInjector = -3,
ProcessNotFound = -501,
}
} }