mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-31 22:00:48 +08:00
[host] wired the sdl session, save-data paths and project references
This commit is contained in:
@@ -18,11 +18,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageVersion Include="NLayer" Version="1.14.0" />
|
<PackageVersion Include="NLayer" Version="1.14.0" />
|
||||||
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
|
<PackageVersion Include="ppy.SDL3-CS" Version="2026.629.0" />
|
||||||
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
|
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
|
||||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
|
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
|
||||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
|
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
|
||||||
<PackageVersion Include="Silk.NET.Windowing" Version="2.23.0" />
|
|
||||||
<!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. -->
|
<!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. -->
|
||||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
|
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj" />
|
||||||
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
|
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
|
||||||
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
|
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
|
||||||
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
|
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
|
||||||
|
|||||||
+270
-98
@@ -8,6 +8,7 @@ using SharpEmu.HLE;
|
|||||||
using SharpEmu.Libs.VideoOut;
|
using SharpEmu.Libs.VideoOut;
|
||||||
using SharpEmu.Logging;
|
using SharpEmu.Logging;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Loader;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ internal static partial class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
private static int Main(string[] args)
|
private static int Main(string[] args)
|
||||||
{
|
{
|
||||||
|
ConfigureManagedPluginResolution();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Run(args);
|
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)
|
private static int Run(string[] args)
|
||||||
{
|
{
|
||||||
if (Updater.TryApply(args, out var updateExitCode))
|
if (Updater.TryApply(args, out var updateExitCode))
|
||||||
@@ -64,7 +86,6 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||||
PreloadGlfw();
|
|
||||||
|
|
||||||
if (args.Length == 0)
|
if (args.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -93,14 +114,9 @@ internal static partial class Program
|
|||||||
PreloadMacVulkanLoader();
|
PreloadMacVulkanLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
// GLFW requires window creation and event processing on the
|
// SDL/AppKit window work belongs on the process main thread on
|
||||||
// process main thread: AppKit demands it on macOS, and X11 has a
|
// macOS. Linux uses the same model for consistent X11/Wayland
|
||||||
// single event queue that must be serviced from the main thread
|
// event ownership. Emulation remains on a worker 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.
|
|
||||||
var exitCode = 0;
|
var exitCode = 0;
|
||||||
HostMainThread.Enable();
|
HostMainThread.Enable();
|
||||||
var emulation = new Thread(() =>
|
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
|
/// 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
|
/// 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
|
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
|
||||||
/// whole process, so it still reports as X64 here). An arm64 process
|
/// whole process, so it still reports as X64 here). Failing up front on
|
||||||
/// (e.g. the osx-arm64 build) can browse the GUI but cannot run games;
|
/// any other process architecture distinguishes that from MoltenVK,
|
||||||
/// failing up front distinguishes that from MoltenVK, signal-handler,
|
/// signal-handler, or guest-memory startup problems.
|
||||||
/// or guest-memory startup problems.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool CheckHostArchitecture()
|
private static bool CheckHostArchitecture()
|
||||||
{
|
{
|
||||||
@@ -178,11 +193,11 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
/// Homebrew's Vulkan libraries are arm64-only and cannot load into this
|
||||||
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
|
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
|
||||||
/// next to the executable (named libvulkan.1.dylib) is preloaded here;
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void PreloadMacVulkanLoader()
|
private static void PreloadMacVulkanLoader()
|
||||||
{
|
{
|
||||||
@@ -214,27 +229,6 @@ internal static partial class Program
|
|||||||
"as libvulkan.1.dylib.");
|
"as libvulkan.1.dylib.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <see cref="PreloadMacVulkanLoader"/> already relies on
|
|
||||||
/// for the Vulkan loader.
|
|
||||||
/// </summary>
|
|
||||||
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)
|
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
||||||
@@ -244,17 +238,13 @@ internal static partial class Program
|
|||||||
return childExitCode;
|
return childExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
|
if (!TryParseArguments(
|
||||||
{
|
args,
|
||||||
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
|
out var ebootPath,
|
||||||
return 1;
|
out var runtimeOptions,
|
||||||
}
|
out var videoOptions,
|
||||||
|
out var logLevel,
|
||||||
HostSessionControl.SetEmbeddedHostSurface(
|
out var logFilePath))
|
||||||
hostSurface?.WindowHandle ?? 0,
|
|
||||||
hostSurface?.DisplayHandle ?? 0);
|
|
||||||
|
|
||||||
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
return 1;
|
return 1;
|
||||||
@@ -266,6 +256,11 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
SharpEmuLog.MinimumLevel = logLevel;
|
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(BuildInfo.Banner);
|
||||||
Log.Info(HostSystemInfo.Summary);
|
Log.Info(HostSystemInfo.Summary);
|
||||||
@@ -309,12 +304,6 @@ internal static partial class Program
|
|||||||
|
|
||||||
try
|
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);
|
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
||||||
|
|
||||||
OrbisGen2Result result;
|
OrbisGen2Result result;
|
||||||
@@ -384,53 +373,9 @@ internal static partial class Program
|
|||||||
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
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<string> args,
|
|
||||||
out string[] emulatorArgs,
|
|
||||||
out VulkanHostSurface? hostSurface,
|
|
||||||
out string? error)
|
|
||||||
{
|
|
||||||
const string hostSurfacePrefix = "--host-surface=";
|
|
||||||
var remaining = new List<string>(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()
|
private static void EnsureCliConsole()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
@@ -1042,7 +987,7 @@ internal static partial class Program
|
|||||||
|
|
||||||
private static void PrintUsage()
|
private static void PrintUsage()
|
||||||
{
|
{
|
||||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||||
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
|
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.");
|
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,
|
string[] args,
|
||||||
out string ebootPath,
|
out string ebootPath,
|
||||||
out SharpEmuRuntimeOptions runtimeOptions,
|
out SharpEmuRuntimeOptions runtimeOptions,
|
||||||
|
out HostVideoOptions videoOptions,
|
||||||
out LogLevel logLevel,
|
out LogLevel logLevel,
|
||||||
out string? logFilePath)
|
out string? logFilePath)
|
||||||
{
|
{
|
||||||
@@ -1094,6 +1040,7 @@ internal static partial class Program
|
|||||||
{
|
{
|
||||||
ebootPath = string.Empty;
|
ebootPath = string.Empty;
|
||||||
runtimeOptions = default;
|
runtimeOptions = default;
|
||||||
|
videoOptions = HostVideoOptions.Default;
|
||||||
logLevel = SharpEmuLog.MinimumLevel;
|
logLevel = SharpEmuLog.MinimumLevel;
|
||||||
logFilePath = null;
|
logFilePath = null;
|
||||||
return false;
|
return false;
|
||||||
@@ -1102,12 +1049,99 @@ internal static partial class Program
|
|||||||
var strictDynlibResolution = false;
|
var strictDynlibResolution = false;
|
||||||
var importTraceLimit = 0;
|
var importTraceLimit = 0;
|
||||||
var cpuEngine = CpuExecutionEngine.NativeOnly;
|
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;
|
logFilePath = null;
|
||||||
logLevel = SharpEmuLog.MinimumLevel;
|
logLevel = SharpEmuLog.MinimumLevel;
|
||||||
var pathTokens = new List<string>(args.Length);
|
var pathTokens = new List<string>(args.Length);
|
||||||
for (var i = 0; i < args.Length; i++)
|
for (var i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
var argument = args[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))
|
if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
strictDynlibResolution = true;
|
strictDynlibResolution = true;
|
||||||
@@ -1269,9 +1303,147 @@ internal static partial class Program
|
|||||||
StrictDynlibResolution = strictDynlibResolution,
|
StrictDynlibResolution = strictDynlibResolution,
|
||||||
ImportTraceLimit = importTraceLimit,
|
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;
|
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)
|
private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
|
||||||
{
|
{
|
||||||
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
|
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
|||||||
@@ -77,35 +77,37 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
|
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
|
||||||
<Visible>False</Visible>
|
<Visible>False</Visible>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="..\SharpEmu.LibAtrac9\LICENSE.txt">
|
||||||
|
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
|
||||||
|
<TargetPath>licenses\LibAtrac9.txt</TargetPath>
|
||||||
|
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||||
|
<Visible>False</Visible>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
|
<Target Name="KeepLibAtrac9External" BeforeTargets="_ComputeFilesToBundle">
|
||||||
|
<ItemGroup>
|
||||||
|
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)"
|
||||||
|
Condition="'%(Filename)%(Extension)' == 'SharpEmu.LibAtrac9.dll'">
|
||||||
|
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||||
|
<RelativePath>plugins\SharpEmu.LibAtrac9.dll</RelativePath>
|
||||||
|
</ResolvedFileToPublish>
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<!-- Native FFmpeg libraries publish into a subfolder next to the
|
||||||
executable instead of sitting loose beside it, so the publish
|
executable instead of sitting loose beside it, so the publish
|
||||||
directory stays uncluttered as more native deps get added. The folder
|
directory stays uncluttered as more native deps get added. The folder
|
||||||
name is a fixed constant, not derived from the RID/architecture: each
|
name is a fixed constant, not derived from the RID/architecture: each
|
||||||
publish output only ever holds one architecture's binaries anyway, so
|
publish output only ever holds one architecture's binaries anyway, so
|
||||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||||
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
|
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
|
||||||
literal "plugins" folder name. -->
|
name. -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Keep glfw as a loose file in the native subfolder; every other native
|
|
||||||
library is embedded into the single-file bundle. -->
|
|
||||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
|
||||||
<ItemGroup>
|
|
||||||
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
|
|
||||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
|
||||||
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
|
|
||||||
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
|
|
||||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
|
||||||
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
|
|
||||||
</ResolvedFileToPublish>
|
|
||||||
</ItemGroup>
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||||
<FfmpegRuntimeDir>
|
<FfmpegRuntimeDir>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||||||
KernelModuleRegistry.Reset();
|
KernelModuleRegistry.Reset();
|
||||||
var image = LoadImage(normalizedEbootPath);
|
var image = LoadImage(normalizedEbootPath);
|
||||||
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
|
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
|
||||||
|
KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId);
|
||||||
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
|
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
|
||||||
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
|
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
|
||||||
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ using System.Collections.Concurrent;
|
|||||||
namespace SharpEmu.HLE;
|
namespace SharpEmu.HLE;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs work on the real process main thread. macOS only allows AppKit (and
|
/// Runs work on the real process main thread. macOS requires its windowing
|
||||||
/// therefore GLFW windowing) on that thread, so the CLI moves emulation onto
|
/// event loop on that thread, so the CLI moves emulation onto
|
||||||
/// a worker thread, parks the main thread in <see cref="Pump"/>, and the
|
/// a worker thread, parks the main thread in <see cref="Pump"/>, and the
|
||||||
/// video presenter posts its window loop here. On other platforms
|
/// video presenter posts its window loop here. On other platforms
|
||||||
/// <see cref="IsAvailable"/> stays false and nothing changes.
|
/// <see cref="IsAvailable"/> stays false and nothing changes.
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ public static class HostSessionControl
|
|||||||
private static Action<string>? _shutdownHandler;
|
private static Action<string>? _shutdownHandler;
|
||||||
private static string? _pendingShutdownReason;
|
private static string? _pendingShutdownReason;
|
||||||
private static int _shutdownRequested;
|
private static int _shutdownRequested;
|
||||||
private static long _embeddedHostWindow;
|
|
||||||
private static long _embeddedHostDisplay;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates that the active host session is being stopped. Runtime code
|
/// Indicates that the active host session is being stopped. Runtime code
|
||||||
@@ -22,21 +20,6 @@ public static class HostSessionControl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
|
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Native GUI surface used by an isolated emulator child. Input backends
|
|
||||||
/// use it to treat the launcher window as the active game window.
|
|
||||||
/// </summary>
|
|
||||||
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
|
|
||||||
|
|
||||||
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a fresh session after the previous guest has fully left its
|
/// Starts a fresh session after the previous guest has fully left its
|
||||||
/// execution backend.
|
/// execution backend.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ppy.SDL3-CS" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Forces build ordering for the aerolib task below; loaded as a build component,
|
<!-- Forces build ordering for the aerolib task below; loaded as a build component,
|
||||||
never a runtime dependency. -->
|
never a runtime dependency. -->
|
||||||
|
|||||||
@@ -129,6 +129,24 @@ public static partial class KernelMemoryCompatExports
|
|||||||
private static readonly HashSet<string> _negativeStatCache = new(HostFsPathComparer);
|
private static readonly HashSet<string> _negativeStatCache = new(HostFsPathComparer);
|
||||||
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPathComparer);
|
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPathComparer);
|
||||||
private static long _nextFileDescriptor = 2;
|
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<char> 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()
|
internal static int AllocateGuestFileDescriptor()
|
||||||
{
|
{
|
||||||
@@ -5350,7 +5368,7 @@ public static partial class KernelMemoryCompatExports
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "devlog", "app"));
|
root = Path.Combine(ResolveGameLogRoot(), "devlog", "app");
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(root);
|
Directory.CreateDirectory(root);
|
||||||
@@ -5419,14 +5437,20 @@ public static partial class KernelMemoryCompatExports
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "hostapp"));
|
root = Path.Combine(ResolveGameLogRoot(), "hostapp");
|
||||||
Environment.SetEnvironmentVariable(hostappVariableName, root);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(root);
|
Directory.CreateDirectory(root);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveGameLogRoot() =>
|
||||||
|
Path.GetFullPath(Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"user",
|
||||||
|
"game_logs",
|
||||||
|
Volatile.Read(ref _applicationTitleId)));
|
||||||
|
|
||||||
private static string GetPerAppWritableRoot()
|
private static string GetPerAppWritableRoot()
|
||||||
{
|
{
|
||||||
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
|
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public static class SaveDataExports
|
|||||||
private static readonly object _memoryGate = new();
|
private static readonly object _memoryGate = new();
|
||||||
private static readonly HashSet<int> _preparedTransactionResources = [];
|
private static readonly HashSet<int> _preparedTransactionResources = [];
|
||||||
private static string? _titleId;
|
private static string? _titleId;
|
||||||
|
private static int _legacySaveMigrationChecked;
|
||||||
|
|
||||||
public static void ConfigureApplicationInfo(string? titleId)
|
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
|
// Saves are keyed by title id only (single-user emulation) under
|
||||||
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not
|
// user/savedata/<titleId>/; userId is accepted for API fidelity but not
|
||||||
// part of the host path.
|
// part of the host path.
|
||||||
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
|
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
|
||||||
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
|
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
|
||||||
@@ -1133,7 +1134,24 @@ public static class SaveDataExports
|
|||||||
ctx.TryReadUInt64(address + 0x10, out offset);
|
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()
|
private static string ResolveConfiguredTitleId()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace SharpEmu.Libs.SaveData;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Host-side layout and metadata for PS5 save data. Saves live under
|
/// Host-side layout and metadata for PS5 save data. Saves live under
|
||||||
/// <c>~/SharpEmu/Saves/<titleId>/<dirName>/</c> (overridable via
|
/// <c>user/savedata/<titleId>/<dirName>/</c> next to the executable (overridable via
|
||||||
/// <c>SHARPEMU_SAVEDATA_DIR</c>); the game's files are written directly inside a
|
/// <c>SHARPEMU_SAVEDATA_DIR</c>); the game's files are written directly inside a
|
||||||
/// slot through the mounted <c>/savedata0</c> filesystem, and the PS5 UI
|
/// slot through the mounted <c>/savedata0</c> filesystem, and the PS5 UI
|
||||||
/// metadata (title/subtitle/detail/userParam) plus icon live under
|
/// metadata (title/subtitle/detail/userParam) plus icon live under
|
||||||
@@ -17,19 +17,74 @@ namespace SharpEmu.Libs.SaveData;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SaveDataStorage
|
public static class SaveDataStorage
|
||||||
{
|
{
|
||||||
/// <summary>Root of all saves: the env override, else <c>~/SharpEmu/Saves</c>.</summary>
|
/// <summary>Root of all saves: the env override, else the portable <c>user/savedata</c> directory.</summary>
|
||||||
public static string Root(string? overrideDir = null)
|
public static string Root(string? overrideDir = null)
|
||||||
{
|
{
|
||||||
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
||||||
var root = string.IsNullOrWhiteSpace(configured)
|
var root = string.IsNullOrWhiteSpace(configured)
|
||||||
? Path.Combine(
|
? Path.Combine(AppContext.BaseDirectory, "user", "savedata")
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
||||||
"SharpEmu",
|
|
||||||
"Saves")
|
|
||||||
: configured;
|
: configured;
|
||||||
return Path.GetFullPath(root);
|
return Path.GetFullPath(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports saves written by the short-lived profile layout and by the old
|
||||||
|
/// numeric-user layout. Newer destination files are never overwritten.
|
||||||
|
/// </summary>
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Per-title directory: <c><root>/<titleId></c>.</summary>
|
/// <summary>Per-title directory: <c><root>/<titleId></c>.</summary>
|
||||||
public static string TitleRoot(string root, string titleId) =>
|
public static string TitleRoot(string root, string titleId) =>
|
||||||
Path.Combine(root, Sanitize(titleId));
|
Path.Combine(root, Sanitize(titleId));
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\SharpEmu.LibAtrac9\SharpEmu.LibAtrac9.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" />
|
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" />
|
||||||
|
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
|
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
|
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" />
|
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" />
|
||||||
@@ -27,11 +29,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FFmpeg.AutoGen" />
|
<PackageReference Include="FFmpeg.AutoGen" />
|
||||||
<PackageReference Include="NLayer" />
|
<PackageReference Include="NLayer" />
|
||||||
<PackageReference Include="Silk.NET.Input" />
|
<PackageReference Include="ppy.SDL3-CS" />
|
||||||
<PackageReference Include="Silk.NET.Vulkan" />
|
<PackageReference Include="Silk.NET.Vulkan" />
|
||||||
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
|
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
|
||||||
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" />
|
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" />
|
||||||
<PackageReference Include="Silk.NET.Windowing" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,19 +8,20 @@ using Xunit;
|
|||||||
namespace SharpEmu.Libs.Tests;
|
namespace SharpEmu.Libs.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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 in <slot>/sce_sys/param.json. These guard the pure path and
|
||||||
/// metadata logic that the SaveData HLE exports build on.
|
/// metadata logic that the SaveData HLE exports build on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SaveDataStorageTests
|
public sealed class SaveDataStorageTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RootHonorsOverrideAndFallsBackToUserProfile()
|
public void RootHonorsOverrideAndFallsBackToPortableDirectory()
|
||||||
{
|
{
|
||||||
Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves"));
|
Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves"));
|
||||||
|
|
||||||
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
|
Assert.Equal(
|
||||||
Assert.Equal(Path.Combine(home, "SharpEmu", "Saves"), SaveDataStorage.Root());
|
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "user", "savedata")),
|
||||||
|
SaveDataStorage.Root());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user