diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 197e9f3..2c88471 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -45,6 +45,7 @@ internal static partial class Program [STAThread] private static int Main(string[] args) { + RosettaGcBudget.ReexecIfNeeded(args); ConfigureManagedPluginResolution(); if (args.Length > 0 && args[0] == SharpEmu.Core.Diagnostics.WindowsCrashCapture.HelperArgument) diff --git a/src/SharpEmu.CLI/RosettaGcBudget.cs b/src/SharpEmu.CLI/RosettaGcBudget.cs new file mode 100644 index 0000000..6ad3af2 --- /dev/null +++ b/src/SharpEmu.CLI/RosettaGcBudget.cs @@ -0,0 +1,100 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections; +using System.Runtime.InteropServices; + +namespace SharpEmu.CLI; + +/// +/// Under Rosetta 2 every garbage collection suspends the runtime with +/// FlushProcessWriteBuffers, which reads each thread's registers through a Mach call that +/// Rosetta services by parking the thread in its runtime. A signal that reaches a parked +/// thread (guest memory tracking raises thousands per second) trips Rosetta's "expected saved +/// LR to be in translated code" assertion and wedges the process. With the default gen0 budget +/// the emulator collects about twice a second; a 64 MiB budget makes that rare. +/// +/// +/// The GC reads its gen0 budget only from the environment when the runtime starts, so the +/// process re-executes itself once with the variable set. execve keeps the process id. +/// +internal static class RosettaGcBudget +{ + private const string BudgetVariable = "DOTNET_GCgen0size"; + // 64 MiB: a larger budget turns every collection into a blocking gen1/gen2 pass of + // about a second (512 MiB measured 8 such pauses in 150 s of Demon's Souls). + private const string Budget = "0x4000000"; + private const string OptOutVariable = "SHARPEMU_ROSETTA_GC_BUDGET"; + + public static void ReexecIfNeeded(string[] args) + { + if (!OperatingSystem.IsMacOS() || + RuntimeInformation.ProcessArchitecture != Architecture.X64 || + Environment.GetEnvironmentVariable(BudgetVariable) is not null || + Environment.GetEnvironmentVariable(OptOutVariable) == "0" || + !IsTranslated()) + { + return; + } + + var path = Environment.ProcessPath; + if (string.IsNullOrEmpty(path)) + { + return; + } + + var environment = new List(); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + environment.Add($"{entry.Key}={entry.Value}"); + } + environment.Add($"{BudgetVariable}={Budget}"); + + var argv = BuildArguments(path, Environment.GetCommandLineArgs()[0], args); + var nativeArguments = ToNullTerminated(argv); + var nativeEnvironment = ToNullTerminated(environment); + // Only returns on failure; the emulator then runs with the default budget. + _ = Execve(path, nativeArguments, nativeEnvironment); + var error = Marshal.GetLastPInvokeError(); + foreach (var pointer in nativeArguments) Marshal.FreeCoTaskMem(pointer); + foreach (var pointer in nativeEnvironment) Marshal.FreeCoTaskMem(pointer); + Console.Error.WriteLine( + $"[LOADER][WARN] Could not restart with a larger GC budget under Rosetta: errno={error}"); + } + + internal static string[] BuildArguments(string executable, string entryAssembly, string[] args) => + Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase) + ? [executable, entryAssembly, .. args] + : [executable, .. args]; + + private static bool IsTranslated() + { + var value = 0; + var size = (nint)sizeof(int); + return SysctlByName("sysctl.proc_translated", ref value, ref size, 0, 0) == 0 && value == 1; + } + + private static nint[] ToNullTerminated(IReadOnlyList values) + { + var pointers = new nint[values.Count + 1]; + for (var i = 0; i < values.Count; i++) + { + pointers[i] = Marshal.StringToCoTaskMemUTF8(values[i]); + } + return pointers; + } + + [DllImport("libc", EntryPoint = "execve", SetLastError = true)] + private static extern int Execve( + [MarshalAs(UnmanagedType.LPUTF8Str)] string path, + nint[] argv, + nint[] envp); + + [DllImport("libc", EntryPoint = "sysctlbyname")] + private static extern int SysctlByName( + [MarshalAs(UnmanagedType.LPUTF8Str)] string name, + ref int value, + ref nint size, + nint newValue, + nint newSize); +} diff --git a/src/SharpEmu.Libs/Media/HostMovieBridge.cs b/src/SharpEmu.Libs/Media/HostMovieBridge.cs index 27d7678..9873ee7 100644 --- a/src/SharpEmu.Libs/Media/HostMovieBridge.cs +++ b/src/SharpEmu.Libs/Media/HostMovieBridge.cs @@ -44,6 +44,8 @@ internal static class HostMovieBridge private static bool _frameBufferPresented; private static MediaFramePlayback? _playback; private static Timer? _playbackWatchdog; + private static long _watchdogFrameIndex = -1; + private static readonly TimeSpan StalledPlaybackTimeout = TimeSpan.FromSeconds(60); private static long _frameSerial; private static uint _presentationWidth = MaxHostVideoWidth; private static uint _presentationHeight = MaxHostVideoHeight; @@ -326,6 +328,7 @@ internal static class HostMovieBridge private static void ArmPlaybackWatchdogLocked(string hostPath) { _playbackWatchdog?.Dispose(); + _watchdogFrameIndex = -1; var timeout = TryReadBinkInfo(hostPath, out var info) ? GetPlaybackWatchdogTimeout(info) : TimeSpan.FromSeconds(150); @@ -340,6 +343,17 @@ internal static class HostMovieBridge return; } + // Playback follows the guest's own decode and audio clock, so a slow host plays a + // movie slower than real time. Expire only a movie that stopped advancing: forcing EOF + // under a guest decoder that is still mid-stream wedges it on the last frame. + var frameIndex = _playback?.PlaybackProgress.FrameIndex ?? -1; + if (_playback is not null && frameIndex > _watchdogFrameIndex) + { + _watchdogFrameIndex = frameIndex; + _playbackWatchdog?.Change(StalledPlaybackTimeout, Timeout.InfiniteTimeSpan); + return; + } + Console.Error.WriteLine( "[LOADER][WARN] Bink2 host watchdog expired for " + Path.GetFileName(path) + "; advancing to the next movie."); diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs index 1181350..375026b 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.FlipRequests.cs @@ -37,6 +37,8 @@ public static partial class VideoOutExports public int FlipMode; public long FlipArg; public bool GpuQueued; + // In command-stream order: a GPU flip from its packet, a CPU flip once its preparation runs on the graphics queue. + public bool Ordered; public long? ReadyTimestamp; public ulong EventHint; public FlipEventRegistration[]? FlipEvents; @@ -128,6 +130,7 @@ public static partial class VideoOutExports FlipMode = flipMode, FlipArg = flipArg, GpuQueued = gpuQueued, + Ordered = gpuQueued, EventHint = SceVideoOutInternalEventFlip | ((unchecked((ulong)flipArg) & 0x0000_FFFF_FFFF_FFFFUL) << 16), FlipEventCount = port.FlipEvents.Count, @@ -296,14 +299,30 @@ public static partial class VideoOutExports } } + // The graphics queue reached the preparation of a CPU flip, so later waits on its buffer are ordered after it. + internal static void MarkFlipOrdered(ulong requestId) + { + lock (_stateGate) + { + if (_flipRequests.TryGetValue(requestId, out var request)) + { + request.Ordered = true; + } + } + } + // True when no flip still waits for the presenter on the buffer; a discarded or cancelled flip no longer does. + // A CPU flip counts only once the graphics queue has reached it: sceVideoOutSubmitFlip reserves the request + // (and may sleep for pacing) before queueing its preparation, so a command buffer submitted earlier must not + // wait for it; the preparation sits behind that command buffer and the wait would never end. internal static bool IsFlipDone(int handle, int bufferIndex) { lock (_stateGate) { foreach (var request in _flipRequests.Values) { - if (request.Handle == handle && request.BufferIndex == bufferIndex && request.Outcome == FlipOutcome.Pending) + if (request.Handle == handle && request.BufferIndex == bufferIndex && request.Ordered && + request.Outcome == FlipOutcome.Pending) { return false; } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs index b4ded39..02cdf51 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs @@ -474,8 +474,11 @@ internal static unsafe partial class VulkanVideoPresenter public bool IsFlipDone(int handle, int index) => VideoOutExports.IsFlipDone(handle, index); - public void PrepareCpuFlip(int handle, int index, ulong requestId) => + public void PrepareCpuFlip(int handle, int index, ulong requestId) + { + VideoOutExports.MarkFlipOrdered(requestId); CaptureFlip(handle, index, requestId, flipMode: 0, flipArg: 0); + } public void DrawIndexed(ulong submitId, in DrawIndexedArguments arguments) { diff --git a/tests/SharpEmu.Libs.Tests/Cli/RosettaGcBudgetTests.cs b/tests/SharpEmu.Libs.Tests/Cli/RosettaGcBudgetTests.cs new file mode 100644 index 0000000..52537c1 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Cli/RosettaGcBudgetTests.cs @@ -0,0 +1,26 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.CLI; +using Xunit; + +namespace SharpEmu.Libs.Tests.Cli; + +public sealed class RosettaGcBudgetTests +{ + [Fact] + public void DotnetRestartKeepsTheManagedEntryAssemblyAndGuestArguments() + { + Assert.Equal(new[] { "/runtime/dotnet", "/emulator/SharpEmu.CLI.dll", "--hdr=off", "/games/My Game/eboot.bin" }, + RosettaGcBudget.BuildArguments("/runtime/dotnet", "/emulator/SharpEmu.CLI.dll", + ["--hdr=off", "/games/My Game/eboot.bin"])); + } + + [Fact] + public void AppHostRestartDoesNotPassTheManagedAssemblyAsAGuestArgument() + { + Assert.Equal(new[] { "/emulator/SharpEmu.CLI", "--hdr=off", "/games/My Game/eboot.bin" }, + RosettaGcBudget.BuildArguments("/emulator/SharpEmu.CLI", "/emulator/SharpEmu.CLI.dll", + ["--hdr=off", "/games/My Game/eboot.bin"])); + } +} diff --git a/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj b/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj index 6694cda..95d063b 100644 --- a/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj +++ b/tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj @@ -24,4 +24,8 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + + diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs index 3cb5b8a..c2c9fc5 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs @@ -414,6 +414,21 @@ public sealed class VideoOutFlipRequestTests : IDisposable Assert.Null(VideoOutExports.GetFlipOutcomeForTests(request)); } + // A CPU flip reserved before its preparation reaches the graphics queue must not hold back an earlier wait. + [Fact] + public void CpuFlip_BlocksBufferWaitsOnlyOnceTheGraphicsQueueReachesIt() + { + Assert.Equal(0, VideoOutExports.TryReserveFlipRequest(_handle, -1, 0, 13, gpuQueued: false, out var request)); + Assert.True(VideoOutExports.IsFlipDone(_handle, -1)); + + VideoOutExports.MarkFlipOrdered(request); + Assert.False(VideoOutExports.IsFlipDone(_handle, -1)); + + VideoOutExports.CompleteFlip(request); + VideoOutExports.MarkFlipPresented(request); + Assert.True(VideoOutExports.IsFlipDone(_handle, -1)); + } + // Update completion counters even if the presenter has already finished the frame. [Fact] public void PresentThenComplete_StillCountsTheFlip()