diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16abd07..84ac6a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,8 @@ + + # Contributing Contributions are always welcome! diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 43ae5e5..caf710d 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -8,12 +8,15 @@ using SharpEmu.HLE; using SharpEmu.Logging; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; namespace SharpEmu.CLI; internal static partial class Program { private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.CLI"); + private static readonly object ConsoleMirrorSync = new(); + private static StreamWriter? _consoleMirrorFile; private const int DefaultImportTraceLimit = 32; private const string MitigatedChildFlag = "--sharpemu-mitigated-child"; private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000; @@ -21,11 +24,15 @@ internal static partial class Program private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007; private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; private const int JobObjectExtendedLimitInformation = 9; + private const int STARTF_USESTDHANDLES = 0x00000100; + private const uint HANDLE_FLAG_INHERIT = 0x00000001; + private const string MitigatedChildEnvironment = "SHARPEMU_MITIGATED_CHILD"; private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40; private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28; private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32; private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40; private const int ATTACH_PARENT_PROCESS = -1; + private const int STD_INPUT_HANDLE = -10; private const int STD_OUTPUT_HANDLE = -11; private const int STD_ERROR_HANDLE = -12; private const uint GENERIC_READ = 0x80000000; @@ -43,6 +50,7 @@ internal static partial class Program } finally { + DropConsoleFileMirror(); SharpEmuLog.Shutdown(); } } @@ -61,6 +69,10 @@ internal static partial class Program // itself to a console before the first write. EnsureCliConsole(); UseUtf8ConsoleOutput(); + if (isMitigatedChild && TryGetLogFileArgument(args, out var earlyLogFilePath)) + { + TryEnableConsoleFileMirror(earlyLogFilePath); + } Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args"); @@ -69,12 +81,17 @@ internal static partial class Program return childExitCode; } - if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel)) + if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath)) { PrintUsage(); return 1; } + if (!isMitigatedChild && !string.IsNullOrWhiteSpace(logFilePath)) + { + TryEnableConsoleFileMirror(logFilePath); + } + SharpEmuLog.MinimumLevel = logLevel; Log.Info(BuildInfo.Banner); @@ -234,6 +251,10 @@ internal static partial class Program private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild) { isMitigatedChild = false; + var trustedMitigatedChild = string.Equals( + Environment.GetEnvironmentVariable(MitigatedChildEnvironment), + "1", + StringComparison.Ordinal); if (args.Length == 0) { return args; @@ -244,7 +265,7 @@ internal static partial class Program { if (string.Equals(arg, MitigatedChildFlag, StringComparison.Ordinal)) { - isMitigatedChild = true; + isMitigatedChild = trustedMitigatedChild; continue; } @@ -283,9 +304,11 @@ internal static partial class Program var commandLine = BuildCommandLine(processPath, childArgs); var startupInfoEx = new STARTUPINFOEX(); startupInfoEx.StartupInfo.cb = Marshal.SizeOf(); + ConfigureInheritedStdHandles(ref startupInfoEx.StartupInfo); nint attributeList = 0; nint mitigationPolicies = 0; + var previousChildEnvironment = Environment.GetEnvironmentVariable(MitigatedChildEnvironment); try { nuint attributeListSize = 0; @@ -293,7 +316,9 @@ internal static partial class Program attributeList = Marshal.AllocHGlobal((nint)attributeListSize); if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize)) { - return false; + childExitCode = 5; + Console.Error.WriteLine($"[ERROR] Failed to initialize mitigation attributes: {Marshal.GetLastWin32Error()}"); + return true; } startupInfoEx.lpAttributeList = attributeList; @@ -301,8 +326,7 @@ internal static partial class Program var policy1 = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF; var policy2 = PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF | - PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF | - PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF; + PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF; mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2); Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1)); @@ -317,24 +341,31 @@ internal static partial class Program 0, 0)) { - return false; + childExitCode = 5; + Console.Error.WriteLine($"[ERROR] Failed to apply mitigation attributes: {Marshal.GetLastWin32Error()}"); + return true; } var cmdLineBuilder = new StringBuilder(commandLine); nint jobHandle = 0; - if (!CreateProcessW( + Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1"); + var created = CreateProcessW( processPath, cmdLineBuilder, 0, 0, - false, + true, EXTENDED_STARTUPINFO_PRESENT, 0, Environment.CurrentDirectory, ref startupInfoEx, - out var processInfo)) + out var processInfo); + Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousChildEnvironment); + if (!created) { - return false; + childExitCode = 5; + Console.Error.WriteLine($"[ERROR] Failed to launch mitigated child process: {Marshal.GetLastWin32Error()}"); + return true; } try @@ -388,6 +419,8 @@ internal static partial class Program } finally { + Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousChildEnvironment); + if (attributeList != 0) { DeleteProcThreadAttributeList(attributeList); @@ -401,6 +434,238 @@ internal static partial class Program } } + private static bool TryGetLogFileArgument(IReadOnlyList args, out string path) + { + for (var i = 0; i < args.Count; i++) + { + var argument = args[i]; + if (string.Equals(argument, "--log-file", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 < args.Count && + !string.IsNullOrWhiteSpace(args[i + 1]) && + !args[i + 1].StartsWith("--", StringComparison.Ordinal) && + ShouldConsumeLogFilePath(args, i + 1)) + { + path = args[i + 1]; + return true; + } + + path = BuildDefaultLogFilePath(TryFindEbootPathToken(args)); + return true; + } + + const string logFilePrefix = "--log-file="; + if (argument.StartsWith(logFilePrefix, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(argument[logFilePrefix.Length..])) + { + path = argument[logFilePrefix.Length..]; + return true; + } + } + + path = string.Empty; + return false; + } + + private static string BuildDefaultLogFilePath(string? ebootPath) + { + var baseDirectory = AppContext.BaseDirectory; + var logsDirectory = Path.Combine(baseDirectory, "user", "logs"); + var name = TryReadTitleId(ebootPath) ?? "UNKNOWN"; + + foreach (var invalid in Path.GetInvalidFileNameChars()) + { + name = name.Replace(invalid, '_'); + } + + return Path.Combine(logsDirectory, $"{name}-{DateTime.Now:yyyyMMdd-HHmmss}.log"); + } + + private static string? TryReadTitleId(string? ebootPath) + { + if (string.IsNullOrWhiteSpace(ebootPath)) + { + return null; + } + + try + { + var directory = Path.GetDirectoryName(Path.GetFullPath(ebootPath)); + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + foreach (var paramPath in new[] + { + Path.Combine(directory, "sce_sys", "param.json"), + Path.Combine(directory, "param.json"), + }) + { + if (!File.Exists(paramPath)) + { + continue; + } + + using var stream = File.OpenRead(paramPath); + using var document = JsonDocument.Parse(stream); + if (document.RootElement.TryGetProperty("titleId", out var titleIdElement) && + titleIdElement.ValueKind == JsonValueKind.String) + { + var titleId = titleIdElement.GetString(); + if (!string.IsNullOrWhiteSpace(titleId)) + { + return titleId.Trim(); + } + } + } + } + catch + { + // Logging should never block launch; unknown title ids use a stable fallback. + } + + return null; + } + + private static string? TryFindEbootPathToken(IReadOnlyList args) + { + for (var i = args.Count - 1; i >= 0; i--) + { + var argument = args[i]; + if (string.IsNullOrWhiteSpace(argument) || + argument.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + return argument; + } + + return null; + } + + private static bool ShouldConsumeLogFilePath(IReadOnlyList args, int candidateIndex) + { + var candidate = args[candidateIndex]; + if (LooksLikeLogFilePath(candidate)) + { + return true; + } + + for (var i = candidateIndex + 1; i < args.Count; i++) + { + var argument = args[i]; + if (!string.IsNullOrWhiteSpace(argument) && + !argument.StartsWith("--", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool LooksLikeLogFilePath(string path) + { + var extension = Path.GetExtension(path); + return string.Equals(extension, ".log", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase); + } + + private static void TryEnableConsoleFileMirror(string path) + { + lock (ConsoleMirrorSync) + { + if (_consoleMirrorFile is not null) + { + return; + } + + try + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var stream = new FileStream( + path, + FileMode.Create, + FileAccess.Write, + FileShare.ReadWrite, + bufferSize: 4096, + FileOptions.SequentialScan); + _consoleMirrorFile = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true, + }; + + Console.SetOut(new TeeTextWriter(Console.Out, _consoleMirrorFile)); + Console.SetError(new TeeTextWriter(Console.Error, _consoleMirrorFile)); + Console.Error.WriteLine($"[DEBUG] Log file: {Path.GetFullPath(path)}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[WARN] Could not open log file '{path}': {ex.Message}"); + } + } + } + + private static void DropConsoleFileMirror() + { + lock (ConsoleMirrorSync) + { + try + { + _consoleMirrorFile?.Flush(); + _consoleMirrorFile?.Dispose(); + } + catch + { + } + + _consoleMirrorFile = null; + } + } + + private static void ConfigureInheritedStdHandles(ref STARTUPINFO startupInfo) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var input = GetStdHandle(STD_INPUT_HANDLE); + var output = GetStdHandle(STD_OUTPUT_HANDLE); + var error = GetStdHandle(STD_ERROR_HANDLE); + if (!IsHandleValid(output) && !IsHandleValid(error)) + { + return; + } + + if (IsHandleValid(input)) + { + _ = SetHandleInformation(input, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + startupInfo.hStdInput = input; + } + + if (IsHandleValid(output)) + { + _ = SetHandleInformation(output, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + startupInfo.hStdOutput = output; + } + + if (IsHandleValid(error)) + { + _ = SetHandleInformation(error, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + startupInfo.hStdError = error; + } + + startupInfo.dwFlags |= STARTF_USESTDHANDLES; + } + private static string BuildCommandLine(string processPath, IReadOnlyList args) { var builder = new StringBuilder(); @@ -503,27 +768,30 @@ internal static partial class Program private static void PrintUsage() { - Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] "); - Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug ""E:\Games\...\eboot.bin"""); + Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] "); + Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); } private static bool TryParseArguments( string[] args, out string ebootPath, out SharpEmuRuntimeOptions runtimeOptions, - out LogLevel logLevel) + out LogLevel logLevel, + out string? logFilePath) { if (args.Length == 0) { ebootPath = string.Empty; runtimeOptions = default; logLevel = SharpEmuLog.MinimumLevel; + logFilePath = null; return false; } var strictDynlibResolution = false; var importTraceLimit = 0; var cpuEngine = CpuExecutionEngine.NativeOnly; + logFilePath = null; logLevel = SharpEmuLog.MinimumLevel; var pathTokens = new List(args.Length); for (var i = 0; i < args.Length; i++) @@ -553,6 +821,7 @@ internal static partial class Program { ebootPath = string.Empty; runtimeOptions = default; + logFilePath = null; return false; } @@ -566,6 +835,7 @@ internal static partial class Program { ebootPath = string.Empty; runtimeOptions = default; + logFilePath = null; return false; } @@ -573,6 +843,23 @@ internal static partial class Program continue; } + if (string.Equals(argument, "--log-file", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 < args.Length && + !string.IsNullOrWhiteSpace(args[i + 1]) && + !args[i + 1].StartsWith("--", StringComparison.Ordinal) && + ShouldConsumeLogFilePath(args, i + 1)) + { + logFilePath = args[++i]; + } + else + { + logFilePath = BuildDefaultLogFilePath(TryFindEbootPathToken(args)); + } + + continue; + } + const string logLevelPrefix = "--log-level="; if (argument.StartsWith(logLevelPrefix, StringComparison.OrdinalIgnoreCase)) { @@ -596,6 +883,7 @@ internal static partial class Program ebootPath = string.Empty; runtimeOptions = default; logLevel = SharpEmuLog.MinimumLevel; + logFilePath = null; return false; } @@ -618,11 +906,27 @@ internal static partial class Program continue; } + const string logFilePrefix = "--log-file="; + if (argument.StartsWith(logFilePrefix, StringComparison.OrdinalIgnoreCase)) + { + logFilePath = argument[logFilePrefix.Length..]; + if (string.IsNullOrWhiteSpace(logFilePath)) + { + ebootPath = string.Empty; + runtimeOptions = default; + logLevel = SharpEmuLog.MinimumLevel; + return false; + } + + continue; + } + if (argument.StartsWith("--", StringComparison.Ordinal)) { ebootPath = string.Empty; runtimeOptions = default; logLevel = SharpEmuLog.MinimumLevel; + logFilePath = null; return false; } @@ -634,6 +938,7 @@ internal static partial class Program ebootPath = string.Empty; runtimeOptions = default; logLevel = SharpEmuLog.MinimumLevel; + logFilePath = null; return false; } @@ -735,6 +1040,56 @@ internal static partial class Program public nuint PeakJobMemoryUsed; } + private sealed class TeeTextWriter : TextWriter + { + private readonly TextWriter _primary; + private readonly TextWriter _mirror; + + public TeeTextWriter(TextWriter primary, TextWriter mirror) + { + _primary = primary; + _mirror = mirror; + } + + public override Encoding Encoding => _primary.Encoding; + + public override void Write(char value) + { + lock (ConsoleMirrorSync) + { + _primary.Write(value); + _mirror.Write(value); + } + } + + public override void Write(string? value) + { + lock (ConsoleMirrorSync) + { + _primary.Write(value); + _mirror.Write(value); + } + } + + public override void WriteLine(string? value) + { + lock (ConsoleMirrorSync) + { + _primary.WriteLine(value); + _mirror.WriteLine(value); + } + } + + public override void Flush() + { + lock (ConsoleMirrorSync) + { + _primary.Flush(); + _mirror.Flush(); + } + } + } + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool InitializeProcThreadAttributeList( @@ -819,6 +1174,10 @@ internal static partial class Program [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetStdHandle(int stdHandle, nint handle); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation(nint handle, uint mask, uint flags); + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)] private static extern nint CreateFileW( string fileName, diff --git a/src/SharpEmu.GUI/EmulatorProcess.cs b/src/SharpEmu.GUI/EmulatorProcess.cs index c713456..4a70507 100644 --- a/src/SharpEmu.GUI/EmulatorProcess.cs +++ b/src/SharpEmu.GUI/EmulatorProcess.cs @@ -168,8 +168,7 @@ internal sealed class EmulatorProcess : IDisposable var policy1 = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF; var policy2 = PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF | - PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF | - PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF; + PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF; mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2); Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1)); @@ -200,29 +199,10 @@ internal sealed class EmulatorProcess : IDisposable ref startupInfoEx, out var processInfo); - if (!created) - { - // Some mitigation policy bits (e.g. XFG) are not supported on - // older Windows builds. Mirror the CLI's behavior and fall back - // to launching without the mitigation attribute list. - startupInfoEx.lpAttributeList = 0; - created = CreateProcessW( - exePath, - new StringBuilder(BuildCommandLine(exePath, arguments)), - 0, - 0, - true, - CREATE_NO_WINDOW, - 0, - currentDirectory, - ref startupInfoEx, - out processInfo); - } - if (!created) { var error = Marshal.GetLastWin32Error(); - throw new Win32Exception(error, $"Failed to start '{exePath}' (Win32 error {error}: {new Win32Exception(error).Message})."); + throw new Win32Exception(error, $"Failed to start '{exePath}' with CET/CFG mitigation disabled (Win32 error {error}: {new Win32Exception(error).Message})."); } CloseHandle(processInfo.hThread); diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs index ca386f5..b742181 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml.cs +++ b/src/SharpEmu.GUI/MainWindow.axaml.cs @@ -1061,13 +1061,11 @@ public partial class MainWindow : Window arguments.Add($"--trace-imports={_settings.ImportTraceLimit}"); } - arguments.Add(ebootPath); - _consoleLines.Clear(); ConsoleToggle.IsChecked = true; - // Mirror everything the console pane shows into a log file for the - // duration of the run, regardless of the emulator's log level. + // Let the CLI mirror stdout/stderr itself; it sees loader/native + // diagnostics before the GUI pipe reader can filter or batch them. DropFileLog(); if (_settings.LogToFile) { @@ -1105,18 +1103,14 @@ public partial class MainWindow : Window if (!string.IsNullOrEmpty(filePath)) { - try - { - _fileLog = new StreamWriter(filePath, append: false); - AppendConsoleLine($"Log file: {filePath}", DimLineBrush); - } - catch (Exception ex) - { - AppendConsoleLine($"Could not open the log file: {ex.Message}", WarningLineBrush); - } + arguments.Add("--log-file"); + arguments.Add(filePath); + AppendConsoleLine($"Log file: {filePath}", DimLineBrush); } } + arguments.Add(ebootPath); + AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush); var emulator = new EmulatorProcess();