mirror of
https://github.com/gwdevhub/Daybreak.git
synced 2026-07-15 15:19:57 +00:00
Improve exe matching on Linux (#1567)
Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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<DaybreakInjector> 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;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace Daybreak.Linux.Services.Wine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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>
|
||||
/// <returns>The Linux PID, or null if not found.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Daybreak.Linux.Services.Wine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class WinePidMapper(
|
||||
IWinePrefixManager winePrefixManager,
|
||||
ILogger<WinePidMapper> logger
|
||||
) : IWinePidMapper
|
||||
{
|
||||
private readonly IWinePrefixManager winePrefixManager = winePrefixManager;
|
||||
private readonly ILogger<WinePidMapper> logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -113,113 +110,4 @@ public sealed class WinePidMapper(
|
||||
|
||||
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,
|
||||
InvalidThreadHandle = -400,
|
||||
}
|
||||
|
||||
public enum ResolveResult
|
||||
{
|
||||
Success = 0,
|
||||
InvalidArgs = -1,
|
||||
InvalidInjector = -3,
|
||||
ProcessNotFound = -501,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user