[host] wired the sdl session, save-data paths and project references

This commit is contained in:
ParantezTech
2026-07-28 01:05:55 +03:00
parent 12432f8fa2
commit c32ba52ca6
14 changed files with 542 additions and 153 deletions
+1 -2
View File
@@ -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" />
+1
View File
@@ -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
View File
@@ -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) ||
+19 -17
View File
@@ -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);
+2 -2
View File
@@ -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.
-17
View File
@@ -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.
+4
View File
@@ -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");
+20 -2
View File
@@ -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()
{ {
+61 -6
View File
@@ -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/&lt;titleId&gt;/&lt;dirName&gt;/</c> (overridable via /// <c>user/savedata/&lt;titleId&gt;/&lt;dirName&gt;/</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>&lt;root&gt;/&lt;titleId&gt;</c>.</summary> /// <summary>Per-title directory: <c>&lt;root&gt;/&lt;titleId&gt;</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));
+3 -2
View File
@@ -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/&lt;titleId&gt;/&lt;dirName&gt;/ with UI /// Save data lives under user/savedata/&lt;titleId&gt;/&lt;dirName&gt;/ with UI
/// metadata in &lt;slot&gt;/sce_sys/param.json. These guard the pure path and /// metadata in &lt;slot&gt;/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);
}
}
}
} }