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, + } }