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()