diff --git a/Directory.Packages.props b/Directory.Packages.props
index 267a07aa..56deb2ea 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,11 +18,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
-
diff --git a/SharpEmu.slnx b/SharpEmu.slnx
index a59b4889..f7eafb5a 100644
--- a/SharpEmu.slnx
+++ b/SharpEmu.slnx
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs
index 868a7cb3..d673e43f 100644
--- a/src/SharpEmu.CLI/Program.cs
+++ b/src/SharpEmu.CLI/Program.cs
@@ -8,6 +8,7 @@ using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using SharpEmu.Logging;
using System.Runtime.InteropServices;
+using System.Runtime.Loader;
using System.Text;
using System.Text.Json;
@@ -45,6 +46,8 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
+ ConfigureManagedPluginResolution();
+
try
{
return Run(args);
@@ -56,6 +59,25 @@ internal static partial class Program
}
}
+ private static void ConfigureManagedPluginResolution()
+ {
+ AssemblyLoadContext.Default.Resolving += static (loadContext, assemblyName) =>
+ {
+ if (string.IsNullOrWhiteSpace(assemblyName.Name))
+ {
+ return null;
+ }
+
+ var assemblyPath = Path.Combine(
+ AppContext.BaseDirectory,
+ "plugins",
+ assemblyName.Name + ".dll");
+ return File.Exists(assemblyPath)
+ ? loadContext.LoadFromAssemblyPath(assemblyPath)
+ : null;
+ };
+ }
+
private static int Run(string[] args)
{
if (Updater.TryApply(args, out var updateExitCode))
@@ -64,7 +86,6 @@ internal static partial class Program
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
- PreloadGlfw();
if (args.Length == 0)
{
@@ -93,14 +114,9 @@ internal static partial class Program
PreloadMacVulkanLoader();
}
- // GLFW requires window creation and event processing on the
- // process main thread: AppKit demands it on macOS, and X11 has a
- // single event queue that must be serviced from the main thread
- // (a window created and polled off it may never map, which showed
- // as a running game with no visible window on Linux). Emulation
- // moves to a worker thread and the main thread services the window
- // work the video presenter posts. Windows keeps a per-thread event
- // queue, so its window stays on the presenter's own thread.
+ // SDL/AppKit window work belongs on the process main thread on
+ // macOS. Linux uses the same model for consistent X11/Wayland
+ // event ownership. Emulation remains on a worker thread.
var exitCode = 0;
HostMainThread.Enable();
var emulation = new Thread(() =>
@@ -131,10 +147,9 @@ internal static partial class Program
/// starts: the CPU backend executes guest x86-64 code natively, so the
/// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
- /// whole process, so it still reports as X64 here). An arm64 process
- /// (e.g. the osx-arm64 build) can browse the GUI but cannot run games;
- /// failing up front distinguishes that from MoltenVK, signal-handler,
- /// or guest-memory startup problems.
+ /// whole process, so it still reports as X64 here). Failing up front on
+ /// any other process architecture distinguishes that from MoltenVK,
+ /// signal-handler, or guest-memory startup problems.
///
private static bool CheckHostArchitecture()
{
@@ -178,11 +193,11 @@ internal static partial class Program
}
///
- /// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib").
+ /// Makes a Vulkan loader visible before SDL creates its Vulkan surface.
/// Homebrew's Vulkan libraries are arm64-only and cannot load into this
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
/// next to the executable (named libvulkan.1.dylib) is preloaded here;
- /// dyld then resolves GLFW's bare-name dlopen to the loaded image.
+ /// dyld can then resolve the loader for SDL and Silk.NET.
///
private static void PreloadMacVulkanLoader()
{
@@ -214,27 +229,6 @@ internal static partial class Program
"as libvulkan.1.dylib.");
}
- ///
- /// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
- /// than flat next to the executable, which falls outside the default OS
- /// DLL/dlopen search path. Preloading it here by full path first means
- /// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
- /// library) finds it already loaded in the process and reuses it -- the
- /// same technique already relies on
- /// for the Vulkan loader.
- ///
- private static void PreloadGlfw()
- {
- var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
- : OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
- : "libglfw.so.3";
- var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
- if (File.Exists(candidate))
- {
- NativeLibrary.TryLoad(candidate, out _);
- }
- }
-
private static int RunEmulator(string[] args, bool isMitigatedChild)
{
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -244,17 +238,13 @@ internal static partial class Program
return childExitCode;
}
- if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
- {
- Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
- return 1;
- }
-
- HostSessionControl.SetEmbeddedHostSurface(
- hostSurface?.WindowHandle ?? 0,
- hostSurface?.DisplayHandle ?? 0);
-
- if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
+ if (!TryParseArguments(
+ args,
+ out var ebootPath,
+ out var runtimeOptions,
+ out var videoOptions,
+ out var logLevel,
+ out var logFilePath))
{
PrintUsage();
return 1;
@@ -266,6 +256,11 @@ internal static partial class Program
}
SharpEmuLog.MinimumLevel = logLevel;
+ if (!HostVideoHost.TryConfigureVideo(videoOptions))
+ {
+ Console.Error.WriteLine("[LOADER][ERROR] Video options cannot change while a presenter is active.");
+ return 3;
+ }
Log.Info(BuildInfo.Banner);
Log.Info(HostSystemInfo.Summary);
@@ -309,12 +304,6 @@ internal static partial class Program
try
{
- if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
- {
- Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
- return 3;
- }
-
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
@@ -384,53 +373,9 @@ internal static partial class Program
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
- HostSessionControl.SetEmbeddedHostSurface(0);
- if (hostSurface is not null)
- {
- VulkanVideoHost.RequestClose();
- VulkanVideoHost.DetachSurface(hostSurface);
- hostSurface.Dispose();
- }
}
}
- private static bool TryExtractHostSurfaceArgument(
- IReadOnlyList args,
- out string[] emulatorArgs,
- out VulkanHostSurface? hostSurface,
- out string? error)
- {
- const string hostSurfacePrefix = "--host-surface=";
- var remaining = new List(args.Count);
- hostSurface = null;
- error = null;
- foreach (var argument in args)
- {
- if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
- {
- remaining.Add(argument);
- continue;
- }
-
- if (hostSurface is not null)
- {
- emulatorArgs = [];
- error = "more than one GUI host surface was specified";
- return false;
- }
-
- var descriptor = argument[hostSurfacePrefix.Length..];
- if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
- {
- emulatorArgs = [];
- return false;
- }
- }
-
- emulatorArgs = remaining.ToArray();
- return true;
- }
-
private static void EnsureCliConsole()
{
if (!OperatingSystem.IsWindows())
@@ -1042,7 +987,7 @@ internal static partial class Program
private static void PrintUsage()
{
- Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] [--debug-server[=host:port]] ");
+ Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--debug-server[=host:port]] ");
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
}
@@ -1087,6 +1032,7 @@ internal static partial class Program
string[] args,
out string ebootPath,
out SharpEmuRuntimeOptions runtimeOptions,
+ out HostVideoOptions videoOptions,
out LogLevel logLevel,
out string? logFilePath)
{
@@ -1094,6 +1040,7 @@ internal static partial class Program
{
ebootPath = string.Empty;
runtimeOptions = default;
+ videoOptions = HostVideoOptions.Default;
logLevel = SharpEmuLog.MinimumLevel;
logFilePath = null;
return false;
@@ -1102,12 +1049,99 @@ internal static partial class Program
var strictDynlibResolution = false;
var importTraceLimit = 0;
var cpuEngine = CpuExecutionEngine.NativeOnly;
+ HostWindowMode? windowModeOverride = null;
+ HostScalingMode? scalingModeOverride = null;
+ int? windowWidthOverride = null;
+ int? windowHeightOverride = null;
+ int? displayIndexOverride = null;
+ int? refreshRateOverride = null;
+ bool? vsyncOverride = null;
+ HostHdrMode? hdrModeOverride = null;
+ videoOptions = HostVideoOptions.Default;
logFilePath = null;
logLevel = SharpEmuLog.MinimumLevel;
var pathTokens = new List(args.Length);
for (var i = 0; i < args.Length; i++)
{
var argument = args[i];
+ if (TrySplitOption(argument, "--window-mode", out var windowModeText))
+ {
+ if (!TryParseWindowMode(windowModeText, out var windowMode))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ windowModeOverride = windowMode;
+ continue;
+ }
+ if (TrySplitOption(argument, "--resolution", out var resolutionText))
+ {
+ if (!TryParseResolution(resolutionText, out var windowWidth, out var windowHeight))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ windowWidthOverride = windowWidth;
+ windowHeightOverride = windowHeight;
+ continue;
+ }
+ if (TrySplitOption(argument, "--display", out var displayText))
+ {
+ if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0)
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ displayIndexOverride = displayIndex;
+ continue;
+ }
+ if (TrySplitOption(argument, "--refresh-rate", out var refreshText))
+ {
+ if (!int.TryParse(refreshText, out var refreshRate) || refreshRate < 0)
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ refreshRateOverride = refreshRate;
+ continue;
+ }
+ if (TrySplitOption(argument, "--scaling", out var scalingText))
+ {
+ if (!TryParseScalingMode(scalingText, out var scalingMode))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ scalingModeOverride = scalingMode;
+ continue;
+ }
+ if (TrySplitOption(argument, "--vsync", out var vsyncText))
+ {
+ if (!TryParseSwitch(vsyncText, out var vsync))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ vsyncOverride = vsync;
+ continue;
+ }
+ if (TrySplitOption(argument, "--hdr", out var hdrText))
+ {
+ if (!TryParseHdrMode(hdrText, out var hdrMode))
+ {
+ ebootPath = string.Empty;
+ runtimeOptions = default;
+ return false;
+ }
+ hdrModeOverride = hdrMode;
+ continue;
+ }
if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
{
strictDynlibResolution = true;
@@ -1269,9 +1303,147 @@ internal static partial class Program
StrictDynlibResolution = strictDynlibResolution,
ImportTraceLimit = importTraceLimit,
};
+ var configuredVideoOptions = LoadConfiguredVideoOptions(ebootPath);
+ videoOptions = (configuredVideoOptions with
+ {
+ WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode,
+ ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode,
+ Width = windowWidthOverride ?? configuredVideoOptions.Width,
+ Height = windowHeightOverride ?? configuredVideoOptions.Height,
+ DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex,
+ RefreshRate = refreshRateOverride ?? configuredVideoOptions.RefreshRate,
+ VSync = vsyncOverride ?? configuredVideoOptions.VSync,
+ HdrMode = hdrModeOverride ?? configuredVideoOptions.HdrMode,
+ }).Normalize();
return true;
}
+ private static HostVideoOptions LoadConfiguredVideoOptions(string ebootPath)
+ {
+ var defaults = HostVideoOptions.Default;
+ try
+ {
+ var effective = EffectiveLaunchSettings.Resolve(
+ GuiSettings.Load(),
+ PerGameSettings.Load(TryReadTitleId(ebootPath)));
+
+ var windowMode = TryParseWindowMode(effective.WindowMode, out var parsedWindowMode)
+ ? parsedWindowMode
+ : defaults.WindowMode;
+ var scalingMode = TryParseScalingMode(effective.ScalingMode, out var parsedScalingMode)
+ ? parsedScalingMode
+ : defaults.ScalingMode;
+ var hasResolution = TryParseResolution(
+ effective.Resolution,
+ out var configuredWidth,
+ out var configuredHeight);
+ var hdrMode = TryParseHdrMode(effective.HdrMode, out var parsedHdrMode)
+ ? parsedHdrMode
+ : defaults.HdrMode;
+
+ return new HostVideoOptions
+ {
+ WindowMode = windowMode,
+ ScalingMode = scalingMode,
+ Width = hasResolution ? configuredWidth : defaults.Width,
+ Height = hasResolution ? configuredHeight : defaults.Height,
+ DisplayIndex = effective.DisplayIndex,
+ RefreshRate = effective.RefreshRate,
+ VSync = effective.VSync,
+ HdrMode = hdrMode,
+ }.Normalize();
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] GUI video settings could not be loaded; using defaults: {exception.Message}");
+ return defaults;
+ }
+ }
+
+ private static bool TrySplitOption(string argument, string name, out string value)
+ {
+ var prefix = name + "=";
+ if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ value = argument[prefix.Length..];
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ private static bool TryParseWindowMode(string value, out HostWindowMode mode)
+ {
+ mode = value.ToLowerInvariant() switch
+ {
+ "windowed" => HostWindowMode.Windowed,
+ "borderless" => HostWindowMode.Borderless,
+ "exclusive" or "fullscreen" => HostWindowMode.ExclusiveFullscreen,
+ _ => (HostWindowMode)(-1),
+ };
+ return Enum.IsDefined(mode);
+ }
+
+ private static bool TryParseScalingMode(string value, out HostScalingMode mode)
+ {
+ mode = value.ToLowerInvariant() switch
+ {
+ "fit" => HostScalingMode.Fit,
+ "cover" => HostScalingMode.Cover,
+ "stretch" => HostScalingMode.Stretch,
+ "integer" => HostScalingMode.Integer,
+ _ => (HostScalingMode)(-1),
+ };
+ return Enum.IsDefined(mode);
+ }
+
+ private static bool TryParseHdrMode(string value, out HostHdrMode mode)
+ {
+ mode = value.ToLowerInvariant() switch
+ {
+ "auto" => HostHdrMode.Auto,
+ "on" or "true" or "1" => HostHdrMode.On,
+ "off" or "false" or "0" => HostHdrMode.Off,
+ _ => (HostHdrMode)(-1),
+ };
+ return Enum.IsDefined(mode);
+ }
+
+ private static bool TryParseResolution(string value, out int width, out int height)
+ {
+ var parts = value.Split('x', 'X');
+ if (parts.Length == 2 && int.TryParse(parts[0], out width) && int.TryParse(parts[1], out height) &&
+ width >= 640 && height >= 360)
+ {
+ return true;
+ }
+
+ width = 0;
+ height = 0;
+ return false;
+ }
+
+ private static bool TryParseSwitch(string value, out bool enabled)
+ {
+ if (value is "1" || value.Equals("on", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("true", StringComparison.OrdinalIgnoreCase))
+ {
+ enabled = true;
+ return true;
+ }
+ if (value is "0" || value.Equals("off", StringComparison.OrdinalIgnoreCase) ||
+ value.Equals("false", StringComparison.OrdinalIgnoreCase))
+ {
+ enabled = false;
+ return true;
+ }
+
+ enabled = false;
+ return false;
+ }
+
private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
{
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
index d4ef9e8b..3c18a21c 100644
--- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj
+++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
@@ -77,35 +77,37 @@ SPDX-License-Identifier: GPL-2.0-or-later
Languages\%(Filename)%(Extension)
False
+
+ Always
+ licenses\LibAtrac9.txt
+ true
+ False
+
-
+ FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
+ name. -->
plugins
-
-
-
- <_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
- Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
-
-
- true
- $(NativeLibraryFolderName)/%(Filename)%(Extension)
-
-
-
-
2c92585
diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
index e1c853e2..671f3d57 100644
--- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
+++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
@@ -143,6 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
KernelModuleRegistry.Reset();
var image = LoadImage(normalizedEbootPath);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
+ KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId);
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
diff --git a/src/SharpEmu.HLE/HostMainThread.cs b/src/SharpEmu.HLE/HostMainThread.cs
index bcf94fda..74fa6e62 100644
--- a/src/SharpEmu.HLE/HostMainThread.cs
+++ b/src/SharpEmu.HLE/HostMainThread.cs
@@ -6,8 +6,8 @@ using System.Collections.Concurrent;
namespace SharpEmu.HLE;
///
-/// Runs work on the real process main thread. macOS only allows AppKit (and
-/// therefore GLFW windowing) on that thread, so the CLI moves emulation onto
+/// Runs work on the real process main thread. macOS requires its windowing
+/// event loop on that thread, so the CLI moves emulation onto
/// a worker thread, parks the main thread in , and the
/// video presenter posts its window loop here. On other platforms
/// stays false and nothing changes.
diff --git a/src/SharpEmu.HLE/HostSessionControl.cs b/src/SharpEmu.HLE/HostSessionControl.cs
index dd739179..cda5c33d 100644
--- a/src/SharpEmu.HLE/HostSessionControl.cs
+++ b/src/SharpEmu.HLE/HostSessionControl.cs
@@ -12,8 +12,6 @@ public static class HostSessionControl
private static Action? _shutdownHandler;
private static string? _pendingShutdownReason;
private static int _shutdownRequested;
- private static long _embeddedHostWindow;
- private static long _embeddedHostDisplay;
///
/// Indicates that the active host session is being stopped. Runtime code
@@ -22,21 +20,6 @@ public static class HostSessionControl
///
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
- ///
- /// Native GUI surface used by an isolated emulator child. Input backends
- /// use it to treat the launcher window as the active game window.
- ///
- public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
-
- /// X11 Display* paired with when available.
- public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
-
- public static void SetEmbeddedHostSurface(nint window, nint display = 0)
- {
- Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
- Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
- }
-
///
/// Starts a fresh session after the previous guest has fully left its
/// execution backend.
diff --git a/src/SharpEmu.HLE/SharpEmu.HLE.csproj b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
index e6ee05a5..8932bea0 100644
--- a/src/SharpEmu.HLE/SharpEmu.HLE.csproj
+++ b/src/SharpEmu.HLE/SharpEmu.HLE.csproj
@@ -21,6 +21,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
+
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index 03fc9f40..319ee53d 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -129,6 +129,24 @@ public static partial class KernelMemoryCompatExports
private static readonly HashSet _negativeStatCache = new(HostFsPathComparer);
private static readonly ConcurrentDictionary _aprFileSizeCache = new(HostFsPathComparer);
private static long _nextFileDescriptor = 2;
+ private static string _applicationTitleId = "UNKNOWN";
+
+ public static void ConfigureApplicationInfo(string? titleId)
+ {
+ var value = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId.Trim();
+ Span sanitized = value.Length <= 128
+ ? stackalloc char[value.Length]
+ : new char[value.Length];
+ for (var index = 0; index < value.Length; index++)
+ {
+ var character = value[index];
+ sanitized[index] = char.IsAsciiLetterOrDigit(character) || character is '-' or '_'
+ ? char.ToUpperInvariant(character)
+ : '_';
+ }
+
+ Volatile.Write(ref _applicationTitleId, new string(sanitized));
+ }
internal static int AllocateGuestFileDescriptor()
{
@@ -5350,7 +5368,7 @@ public static partial class KernelMemoryCompatExports
}
else
{
- root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "devlog", "app"));
+ root = Path.Combine(ResolveGameLogRoot(), "devlog", "app");
}
Directory.CreateDirectory(root);
@@ -5419,14 +5437,20 @@ public static partial class KernelMemoryCompatExports
}
else
{
- root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "hostapp"));
- Environment.SetEnvironmentVariable(hostappVariableName, root);
+ root = Path.Combine(ResolveGameLogRoot(), "hostapp");
}
Directory.CreateDirectory(root);
return root;
}
+ private static string ResolveGameLogRoot() =>
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ Volatile.Read(ref _applicationTitleId)));
+
private static string GetPerAppWritableRoot()
{
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
diff --git a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
index 97b5ccbe..e051ddcf 100644
--- a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
+++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs
@@ -38,6 +38,7 @@ public static class SaveDataExports
private static readonly object _memoryGate = new();
private static readonly HashSet _preparedTransactionResources = [];
private static string? _titleId;
+ private static int _legacySaveMigrationChecked;
public static void ConfigureApplicationInfo(string? titleId)
{
@@ -1115,7 +1116,7 @@ public static class SaveDataExports
}
// Saves are keyed by title id only (single-user emulation) under
- // ~/SharpEmu/Saves//; userId is accepted for API fidelity but not
+ // user/savedata//; userId is accepted for API fidelity but not
// part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
@@ -1133,7 +1134,24 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset);
}
- private static string ResolveSaveDataRoot() => SaveDataStorage.Root();
+ private static string ResolveSaveDataRoot()
+ {
+ var root = SaveDataStorage.Root();
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR")) &&
+ Interlocked.Exchange(ref _legacySaveMigrationChecked, 1) == 0)
+ {
+ try
+ {
+ SaveDataStorage.MigrateLegacyLayout(root);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ TraceSaveData($"migration_failed root='{root}' error='{exception.Message}'");
+ }
+ }
+
+ return root;
+ }
private static string ResolveConfiguredTitleId()
{
diff --git a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs
index e8e966e9..b27ed105 100644
--- a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs
+++ b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs
@@ -8,7 +8,7 @@ namespace SharpEmu.Libs.SaveData;
///
/// Host-side layout and metadata for PS5 save data. Saves live under
-/// ~/SharpEmu/Saves/<titleId>/<dirName>/ (overridable via
+/// user/savedata/<titleId>/<dirName>/ next to the executable (overridable via
/// SHARPEMU_SAVEDATA_DIR); the game's files are written directly inside a
/// slot through the mounted /savedata0 filesystem, and the PS5 UI
/// metadata (title/subtitle/detail/userParam) plus icon live under
@@ -17,19 +17,74 @@ namespace SharpEmu.Libs.SaveData;
///
public static class SaveDataStorage
{
- /// Root of all saves: the env override, else ~/SharpEmu/Saves.
+ /// Root of all saves: the env override, else the portable user/savedata directory.
public static string Root(string? overrideDir = null)
{
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
- ? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- "SharpEmu",
- "Saves")
+ ? Path.Combine(AppContext.BaseDirectory, "user", "savedata")
: configured;
return Path.GetFullPath(root);
}
+ ///
+ /// Imports saves written by the short-lived profile layout and by the old
+ /// numeric-user layout. Newer destination files are never overwritten.
+ ///
+ public static void MigrateLegacyLayout(string destinationRoot, string? profileRoot = null)
+ {
+ destinationRoot = Path.GetFullPath(destinationRoot);
+ profileRoot ??= Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "SharpEmu",
+ "Saves");
+
+ if (Directory.Exists(profileRoot) &&
+ !string.Equals(Path.GetFullPath(profileRoot), destinationRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ MergeDirectory(profileRoot, destinationRoot);
+ }
+
+ if (!Directory.Exists(destinationRoot))
+ {
+ return;
+ }
+
+ foreach (var userRoot in Directory.EnumerateDirectories(destinationRoot).ToArray())
+ {
+ if (!uint.TryParse(Path.GetFileName(userRoot), out _))
+ {
+ continue;
+ }
+
+ foreach (var titleRoot in Directory.EnumerateDirectories(userRoot))
+ {
+ MergeDirectory(titleRoot, Path.Combine(destinationRoot, Path.GetFileName(titleRoot)));
+ }
+ }
+ }
+
+ private static void MergeDirectory(string sourceRoot, string destinationRoot)
+ {
+ Directory.CreateDirectory(destinationRoot);
+ foreach (var sourceFile in Directory.EnumerateFiles(sourceRoot))
+ {
+ var destinationFile = Path.Combine(destinationRoot, Path.GetFileName(sourceFile));
+ if (!File.Exists(destinationFile) ||
+ File.GetLastWriteTimeUtc(sourceFile) > File.GetLastWriteTimeUtc(destinationFile))
+ {
+ File.Copy(sourceFile, destinationFile, overwrite: true);
+ }
+ }
+
+ foreach (var sourceDirectory in Directory.EnumerateDirectories(sourceRoot))
+ {
+ MergeDirectory(
+ sourceDirectory,
+ Path.Combine(destinationRoot, Path.GetFileName(sourceDirectory)));
+ }
+ }
+
/// Per-title directory: <root>/<titleId>.
public static string TitleRoot(string root, string titleId) =>
Path.Combine(root, Sanitize(titleId));
diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
index 13169f87..ed4d1e4b 100644
--- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj
+++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj
@@ -5,7 +5,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
@@ -27,11 +29,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
-
diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs
new file mode 100644
index 00000000..90a2d5b0
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelGameLogPathTests.cs
@@ -0,0 +1,96 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.Libs.Kernel;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Kernel;
+
+[Collection(KernelMemoryCompatStateCollection.Name)]
+public sealed class KernelGameLogPathTests : IDisposable
+{
+ private readonly string? _previousHostappRoot =
+ Environment.GetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR");
+ private readonly string? _previousDevlogRoot =
+ Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR");
+
+ public KernelGameLogPathTests()
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", null);
+ Environment.SetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR", null);
+ KernelMemoryCompatExports.ConfigureApplicationInfo("ppsa/01:342");
+ }
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", _previousHostappRoot);
+ Environment.SetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR", _previousDevlogRoot);
+ KernelMemoryCompatExports.ConfigureApplicationInfo(null);
+
+ var testRoot = Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ "PPSA_01_342");
+ if (Directory.Exists(testRoot))
+ {
+ Directory.Delete(testRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void HostappUsesPerTitleGameLogDirectory()
+ {
+ var path = KernelMemoryCompatExports.ResolveGuestPath("/hostapp/logs/game.log");
+
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ "PPSA_01_342",
+ "hostapp",
+ "logs",
+ "game.log")),
+ path);
+ }
+
+ [Fact]
+ public void DevlogUsesPerTitleGameLogDirectory()
+ {
+ var path = KernelMemoryCompatExports.ResolveGuestPath("/devlog/app/debug.log");
+
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "user",
+ "game_logs",
+ "PPSA_01_342",
+ "devlog",
+ "app",
+ "debug.log")),
+ path);
+ }
+
+ [Fact]
+ public void ExplicitHostappOverrideIsPreserved()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", root);
+
+ Assert.Equal(
+ Path.Combine(Path.GetFullPath(root), "logs", "game.log"),
+ KernelMemoryCompatExports.ResolveGuestPath("/hostapp/logs/game.log"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", null);
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs
index 2dc1e8ab..9f6e7385 100644
--- a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs
+++ b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs
@@ -8,19 +8,20 @@ using Xunit;
namespace SharpEmu.Libs.Tests;
///
-/// Save data lives under ~/SharpEmu/Saves/<titleId>/<dirName>/ with UI
+/// Save data lives under user/savedata/<titleId>/<dirName>/ with UI
/// metadata in <slot>/sce_sys/param.json. These guard the pure path and
/// metadata logic that the SaveData HLE exports build on.
///
public sealed class SaveDataStorageTests
{
[Fact]
- public void RootHonorsOverrideAndFallsBackToUserProfile()
+ public void RootHonorsOverrideAndFallsBackToPortableDirectory()
{
Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves"));
- var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
- Assert.Equal(Path.Combine(home, "SharpEmu", "Saves"), SaveDataStorage.Root());
+ Assert.Equal(
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "user", "savedata")),
+ SaveDataStorage.Root());
}
[Fact]
@@ -112,4 +113,36 @@ public sealed class SaveDataStorageTests
}
}
}
+
+ [Fact]
+ public void LegacyMigrationKeepsTheNewestSaveAndFlattensNumericUsers()
+ {
+ var testRoot = Path.Combine(Path.GetTempPath(), "sharpemu-savemigrate-" + Path.GetRandomFileName());
+ var destination = Path.Combine(testRoot, "portable");
+ var profile = Path.Combine(testRoot, "profile");
+ try
+ {
+ var stale = Path.Combine(destination, "268435456", "PPSA02929", "SAVEDATA00", "save.dat");
+ Directory.CreateDirectory(Path.GetDirectoryName(stale)!);
+ File.WriteAllText(stale, "stale");
+ File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddMinutes(-2));
+
+ var current = Path.Combine(profile, "PPSA02929", "SAVEDATA00", "save.dat");
+ Directory.CreateDirectory(Path.GetDirectoryName(current)!);
+ File.WriteAllText(current, "current");
+
+ SaveDataStorage.MigrateLegacyLayout(destination, profile);
+
+ Assert.Equal(
+ "current",
+ File.ReadAllText(Path.Combine(destination, "PPSA02929", "SAVEDATA00", "save.dat")));
+ }
+ finally
+ {
+ if (Directory.Exists(testRoot))
+ {
+ Directory.Delete(testRoot, recursive: true);
+ }
+ }
+ }
}