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); + } + } + } }