diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs
index 60ec8d2..0d43b6d 100644
--- a/src/SharpEmu.CLI/Program.cs
+++ b/src/SharpEmu.CLI/Program.cs
@@ -36,6 +36,18 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
+ {
+ try
+ {
+ return Run(args);
+ }
+ finally
+ {
+ SharpEmuLog.Shutdown();
+ }
+ }
+
+ private static int Run(string[] args)
{
args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild)
@@ -66,7 +78,7 @@ internal static partial class Program
ebootPath = Path.GetFullPath(ebootPath);
Console.Error.WriteLine($"[DEBUG] Full path: {ebootPath}");
-
+
if (!File.Exists(ebootPath))
{
Log.Error($"EBOOT file was not found: {ebootPath}");
diff --git a/src/SharpEmu.Logging/CompositeLogSink.cs b/src/SharpEmu.Logging/CompositeLogSink.cs
new file mode 100644
index 0000000..ae09e12
--- /dev/null
+++ b/src/SharpEmu.Logging/CompositeLogSink.cs
@@ -0,0 +1,68 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+namespace SharpEmu.Logging;
+
+///
+/// Dispatches each to every child sink.
+/// Exceptions thrown by a child are swallowed so one failing sink
+/// (e.g. a closed file) cannot silence the others.
+///
+public sealed class CompositeLogSink : ISharpEmuLogSink, IDisposable
+{
+ private readonly ISharpEmuLogSink[] _sinks;
+ private bool _disposed;
+
+ public CompositeLogSink(params ISharpEmuLogSink[] sinks)
+ {
+ ArgumentNullException.ThrowIfNull(sinks);
+
+ foreach (var sink in sinks)
+ {
+ ArgumentNullException.ThrowIfNull(sink, nameof(sinks));
+ }
+
+ _sinks = sinks;
+ }
+
+ public IReadOnlyList Sinks => _sinks;
+
+ public void Write(in LogEntry entry)
+ {
+ foreach (var sink in _sinks)
+ {
+ try
+ {
+ sink.Write(in entry);
+ }
+ catch
+ {
+ // A broken sink must not prevent the remaining sinks from logging.
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ foreach (var sink in _sinks)
+ {
+ if (sink is IDisposable disposable)
+ {
+ try
+ {
+ disposable.Dispose();
+ }
+ catch
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/src/SharpEmu.Logging/FileLogSink.cs b/src/SharpEmu.Logging/FileLogSink.cs
new file mode 100644
index 0000000..bc2118b
--- /dev/null
+++ b/src/SharpEmu.Logging/FileLogSink.cs
@@ -0,0 +1,115 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.IO;
+using System.Text;
+
+namespace SharpEmu.Logging;
+
+///
+/// Writes log entries to a file. Thread-safe via an internal lock.
+/// is enabled so entries survive a crash.
+///
+public sealed class FileLogSink : ISharpEmuLogSink, IDisposable
+{
+ private readonly object _sync = new();
+ private readonly StreamWriter _writer;
+ private bool _disposed;
+
+ /// Absolute or relative file path. Parent directories are created if missing.
+ /// to append to an existing file; to overwrite.
+ /// Always recommended for file logs — entries include a full date-time prefix.
+ public FileLogSink(string path, bool append = true, bool includeTimestamp = true)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var fileStream = new FileStream(
+ path,
+ append ? FileMode.Append : FileMode.Create,
+ FileAccess.Write,
+ FileShare.Read,
+ bufferSize: 4096,
+ FileOptions.SequentialScan);
+ _writer = new StreamWriter(fileStream, Encoding.UTF8)
+ {
+ AutoFlush = true
+ };
+
+ IncludeTimestamp = includeTimestamp;
+ }
+
+ public bool IncludeTimestamp { get; set; }
+
+ public void Write(in LogEntry entry)
+ {
+ lock (_sync)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (IncludeTimestamp)
+ {
+ _writer.Write('[');
+ _writer.Write(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"));
+ _writer.Write(']');
+ }
+
+ _writer.Write('[');
+ _writer.Write(ToLevelLabel(entry.Level));
+ _writer.Write(']');
+ _writer.Write('[');
+ _writer.Write(entry.Category);
+ _writer.Write(']');
+ _writer.Write(' ');
+
+ _writer.Write(entry.SourceFileName);
+ if (entry.SourceLine > 0)
+ {
+ _writer.Write(':');
+ _writer.Write(entry.SourceLine);
+ }
+
+ _writer.Write(' ');
+ _writer.WriteLine(entry.Message);
+
+ if (entry.Exception is not null)
+ {
+ _writer.WriteLine(entry.Exception);
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_sync)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _writer.Flush();
+ _writer.Dispose();
+ }
+ }
+
+ private static string ToLevelLabel(LogLevel level) => level switch
+ {
+ LogLevel.Trace => "TRACE",
+ LogLevel.Debug => "DEBUG",
+ LogLevel.Info => "INFO",
+ LogLevel.Warning => "WARNING",
+ LogLevel.Error => "ERROR",
+ LogLevel.Critical => "CRITICAL",
+ _ => "LOG",
+ };
+}
diff --git a/src/SharpEmu.Logging/SharpEmuLog.cs b/src/SharpEmu.Logging/SharpEmuLog.cs
index 51a4c76..e3dcac9 100644
--- a/src/SharpEmu.Logging/SharpEmuLog.cs
+++ b/src/SharpEmu.Logging/SharpEmuLog.cs
@@ -12,9 +12,7 @@ public static class SharpEmuLog
new(StringComparer.Ordinal);
private static readonly object ConfigurationSync = new();
private static volatile LogLevel _minimumLevel = ResolveMinimumLevelFromEnvironment();
- private static ISharpEmuLogSink _sink = new ConsoleLogSink(
- useColors: ResolveColorEnabledFromEnvironment(),
- includeTimestamp: false);
+ private static ISharpEmuLogSink _sink = ResolveSinkFromEnvironment();
public static LogLevel MinimumLevel
{
@@ -37,11 +35,26 @@ public static class SharpEmuLog
ArgumentNullException.ThrowIfNull(value);
lock (ConfigurationSync)
{
+ if (ReferenceEquals(_sink, value))
+ {
+ return;
+ }
+
+ if (_sink is IDisposable disposable)
+ {
+ try
+ {
+ disposable.Dispose();
+ }
+ catch
+ {
+ }
+ }
+
_sink = value;
}
}
}
-
public static void Configure(LogLevel? minimumLevel = null, ISharpEmuLogSink? sink = null)
{
if (minimumLevel.HasValue)
@@ -55,6 +68,22 @@ public static class SharpEmuLog
}
}
+ ///
+ /// Disposes the active sink if it implements .
+ /// Call at shutdown to flush file buffers. Logging after this call
+ /// continues to work for non-disposable sinks (e.g. ).
+ ///
+ public static void Shutdown()
+ {
+ lock (ConfigurationSync)
+ {
+ if (_sink is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
+ }
+
public static SharpEmuLogger For(string category)
{
ArgumentException.ThrowIfNullOrWhiteSpace(category);
@@ -146,6 +175,30 @@ public static class SharpEmuLog
return !IsTrueLike(raw);
}
+ private static ISharpEmuLogSink ResolveSinkFromEnvironment()
+ {
+ var consoleSink = new ConsoleLogSink(
+ useColors: ResolveColorEnabledFromEnvironment(),
+ includeTimestamp: false);
+
+ var logFilePath = Environment.GetEnvironmentVariable("SHARPEMU_LOG_FILE");
+ if (!string.IsNullOrWhiteSpace(logFilePath))
+ {
+ try
+ {
+ var fileSink = new FileLogSink(logFilePath, append: true, includeTimestamp: true);
+ return new CompositeLogSink(consoleSink, fileSink);
+ }
+ catch (Exception ex)
+ {
+ // Bootstrapping — the logging system is not yet active, so stderr is the only channel.
+ Console.Error.WriteLine($"[SHARPEMU_LOG] Failed to open log file '{logFilePath}': {ex.Message}");
+ }
+ }
+
+ return consoleSink;
+ }
+
private static bool IsTrueLike(string? text)
{
if (string.IsNullOrWhiteSpace(text))