diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
index b9292480..d773bdf0 100644
--- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
+++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs
@@ -12,6 +12,7 @@ using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.AppContent;
using SharpEmu.Libs.SaveData;
using SharpEmu.Libs.Fiber;
+using SharpEmu.Libs.SystemService;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
@@ -141,6 +142,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
var image = LoadImage(normalizedEbootPath);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
+ SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress);
Console.Error.WriteLine($"[RUNTIME] Entry: 0x{image.EntryPoint:X16}");
diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json
index cbc1876c..803dd63b 100644
--- a/src/SharpEmu.GUI/Languages/en.json
+++ b/src/SharpEmu.GUI/Languages/en.json
@@ -31,9 +31,11 @@
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
"Options.Env.Bthid.Desc": "Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever.\nLeave off normally. Some titles freeze when init fails.",
"Options.Env.LoopGuard.Desc": "Do not force quit titles that repeat the same call for too long.\nTry this when a game exits on its own while loading.",
+ "Options.Env.WritableApp0.Desc": "Allow titles to create and write files inside their install folder.\nNeeded by unpackaged dumps that write their save or config data under /app0.",
"Options.Env.VkValidation.Desc": "Enable Vulkan validation layers for GPU debugging.\nSlow. Requires the Vulkan SDK to be installed.",
"Options.Env.DumpSpirv.Desc": "Dump AGC shaders and their SPIR-V translations to the shader-dumps folder.\nUse when reporting shader or rendering bugs.",
"Options.Env.LogDirectMemory.Desc": "Log direct memory allocations and failures to the console.\nUse when a game aborts or exits during boot.",
+ "Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index d818c076..4b5b69ad 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -420,6 +420,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center" />
+
+
+
+
+
+
+
+
@@ -453,6 +464,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center" />
+
+
+
+
+
+
+
+
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
index 0fbc735a..fab6c8a0 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml.cs
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -136,12 +136,16 @@ public partial class MainWindow : Window
SetEnvironmentToggle("SHARPEMU_BTHID_UNAVAILABLE", EnvBthidToggle.IsChecked == true);
EnvLoopGuardToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", EnvLoopGuardToggle.IsChecked == true);
+ EnvWritableApp0Toggle.IsCheckedChanged += (_, _) =>
+ SetEnvironmentToggle("SHARPEMU_WRITABLE_APP0", EnvWritableApp0Toggle.IsChecked == true);
EnvVkValidationToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_VK_VALIDATION", EnvVkValidationToggle.IsChecked == true);
EnvDumpSpirvToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_DUMP_SPIRV", EnvDumpSpirvToggle.IsChecked == true);
EnvLogDirectMemoryToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_DIRECT_MEMORY", EnvLogDirectMemoryToggle.IsChecked == true);
+ EnvLogIoToggle.IsCheckedChanged += (_, _) =>
+ SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true);
EnvLogNpToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
@@ -427,9 +431,11 @@ public partial class MainWindow : Window
EnvDesc.Text = loc.Get("Options.Env.Desc");
EnvBthidDesc.Text = loc.Get("Options.Env.Bthid.Desc");
EnvLoopGuardDesc.Text = loc.Get("Options.Env.LoopGuard.Desc");
+ EnvWritableApp0Desc.Text = loc.Get("Options.Env.WritableApp0.Desc");
EnvVkValidationDesc.Text = loc.Get("Options.Env.VkValidation.Desc");
EnvDumpSpirvDesc.Text = loc.Get("Options.Env.DumpSpirv.Desc");
EnvLogDirectMemoryDesc.Text = loc.Get("Options.Env.LogDirectMemory.Desc");
+ EnvLogIoDesc.Text = loc.Get("Options.Env.LogIo.Desc");
EnvLogNpDesc.Text = loc.Get("Options.Env.LogNp.Desc");
EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation");
LoggingSectionTitle.Text = loc.Get("Options.Section.Logging");
@@ -614,9 +620,11 @@ public partial class MainWindow : Window
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
EnvLoopGuardToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD");
+ EnvWritableApp0Toggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_WRITABLE_APP0");
EnvVkValidationToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_VK_VALIDATION");
EnvDumpSpirvToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_DUMP_SPIRV");
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
+ EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
UpdateLogFilePathText();
}
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index 81848a7a..4a658346 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -1574,7 +1574,27 @@ public static partial class KernelMemoryCompatExports
ExportName = "stat",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
- public static int PosixStat(CpuContext ctx) => KernelStat(ctx);
+ public static int PosixStat(CpuContext ctx)
+ {
+ var result = KernelStat(ctx);
+ if (result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
+ {
+ return 0;
+ }
+
+ // stat(2) follows the libc/POSIX ABI: failures return -1 and expose
+ // the reason through errno. Returning the raw Orbis kernel code here
+ // makes callers treat a missing file as a non-negative success value.
+ var errno = result switch
+ {
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => Einval,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => Efault,
+ _ => 2, // ENOENT
+ };
+ KernelRuntimeCompatExports.TrySetErrno(ctx, errno);
+ ctx[CpuRegister.Rax] = ulong.MaxValue;
+ return -1;
+ }
[SysAbiExport(
Nid = "gEpBkcwxUjw",
@@ -4219,31 +4239,50 @@ public static partial class KernelMemoryCompatExports
{
private readonly CpuContext _ctx;
private int _gpIndex;
+ private int _fpIndex;
+ private int _stackIndex;
public RegisterPrintfArgumentSource(CpuContext ctx, int gpIndex)
{
_ctx = ctx;
_gpIndex = gpIndex;
+ _fpIndex = 0;
+ _stackIndex = 0;
}
public ulong NextGpArg()
{
- var index = _gpIndex++;
- return index switch
+ var index = _gpIndex;
+ if (index < 6)
{
- 0 => _ctx[CpuRegister.Rdi],
- 1 => _ctx[CpuRegister.Rsi],
- 2 => _ctx[CpuRegister.Rdx],
- 3 => _ctx[CpuRegister.Rcx],
- 4 => _ctx[CpuRegister.R8],
- 5 => _ctx[CpuRegister.R9],
- _ => ReadStackArg(_ctx, (ulong)(index - 6) * 8)
- };
+ _gpIndex++;
+ return index switch
+ {
+ 0 => _ctx[CpuRegister.Rdi],
+ 1 => _ctx[CpuRegister.Rsi],
+ 2 => _ctx[CpuRegister.Rdx],
+ 3 => _ctx[CpuRegister.Rcx],
+ 4 => _ctx[CpuRegister.R8],
+ _ => _ctx[CpuRegister.R9],
+ };
+ }
+
+ return ReadStackArg(_ctx, (ulong)(_stackIndex++) * 8);
}
public double NextFloatArg()
{
- return BitConverter.Int64BitsToDouble(unchecked((long)NextGpArg()));
+ ulong bits;
+ if (_fpIndex < 8)
+ {
+ _ctx.GetXmmRegister(_fpIndex++, out bits, out _);
+ }
+ else
+ {
+ bits = ReadStackArg(_ctx, (ulong)(_stackIndex++) * 8);
+ }
+
+ return BitConverter.Int64BitsToDouble(unchecked((long)bits));
}
}
@@ -4806,8 +4845,19 @@ public static partial class KernelMemoryCompatExports
private static bool IsMutatingOpen(int flags) =>
(flags & (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC | O_APPEND)) != 0;
+ // Dev-build dumps (unpackaged UE titles, etc.) may write their Saved/ tree under
+ // /app0, which is read-only on retail hardware. Opt in via SHARPEMU_WRITABLE_APP0=1
+ // to allow those writes so such dumps can boot; defaults off to keep retail semantics.
+ private static readonly bool _writableApp0 =
+ string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_WRITABLE_APP0"), "1", StringComparison.Ordinal);
+
public static bool IsReadOnlyGuestMutationPath(string guestPath)
{
+ if (_writableApp0)
+ {
+ return false;
+ }
+
var normalized = NormalizeGuestStatCachePath(guestPath);
return normalized is not null &&
(string.Equals(normalized, "/app0", StringComparison.OrdinalIgnoreCase) ||
diff --git a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs
index 669664d1..d039d609 100644
--- a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs
+++ b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs
@@ -43,6 +43,19 @@ public static class NpWebApi2Exports
return ctx.SetReturn(0);
}
+ [SysAbiExport(
+ Nid = "sk54bi6FtYM",
+ ExportName = "sceNpWebApi2CreateUserContext",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceNpWebApi2")]
+ public static int NpWebApi2CreateUserContext(CpuContext ctx)
+ {
+ // No PSN backend: refuse user-context creation so the title's online
+ // layer backs off instead of driving a half-created context handle.
+ TraceNpWebApi2("create-user-context", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
+ return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
+ }
+
[SysAbiExport(
Nid = "bEvXpcEk200",
ExportName = "sceNpWebApi2Terminate",
diff --git a/src/SharpEmu.Libs/Pad/BluetoothHidExports.cs b/src/SharpEmu.Libs/Pad/BluetoothHidExports.cs
index 44703382..13611d02 100644
--- a/src/SharpEmu.Libs/Pad/BluetoothHidExports.cs
+++ b/src/SharpEmu.Libs/Pad/BluetoothHidExports.cs
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+using System.Runtime.InteropServices;
+using System.Threading;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Pad;
@@ -16,6 +18,19 @@ public static class BluetoothHidExports
"1",
StringComparison.Ordinal);
+ // EXPERIMENT: fire the registered callback once with a zeroed event struct.
+ // Direct execution shares the host address space with the guest, so an
+ // AllocHGlobal buffer is directly readable by guest code.
+ // SHARPEMU_BTHID_FIRE_CALLBACK=1 enables; SHARPEMU_BTHID_EVENT_CODE and
+ // SHARPEMU_BTHID_EVENT_SIZE (default 0 / 256) shape the synthetic event.
+ private static readonly bool _fireCallback = string.Equals(
+ Environment.GetEnvironmentVariable("SHARPEMU_BTHID_FIRE_CALLBACK"),
+ "1",
+ StringComparison.Ordinal);
+
+ private static ulong _callbackFunction;
+ private static int _fired;
+
private static int Result(CpuContext ctx) =>
ctx.SetReturn(_reportUnavailable ? BluetoothHidUnavailable : 0);
@@ -31,12 +46,101 @@ public static class BluetoothHidExports
ExportName = "sceBluetoothHidRegisterDevice",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceBluetoothHid")]
- public static int BluetoothHidRegisterDevice(CpuContext ctx) => Result(ctx);
+ public static int BluetoothHidRegisterDevice(CpuContext ctx)
+ {
+ var result = Result(ctx);
+ if (_fireCallback && _callbackFunction >= 0x10000 &&
+ Interlocked.Exchange(ref _fired, 1) == 0)
+ {
+ FireEnumerationComplete(ctx);
+ }
+
+ return result;
+ }
[SysAbiExport(
Nid = "4Ypfo9RIwfM",
ExportName = "sceBluetoothHidRegisterCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceBluetoothHid")]
- public static int BluetoothHidRegisterCallback(CpuContext ctx) => Result(ctx);
+ public static int BluetoothHidRegisterCallback(CpuContext ctx)
+ {
+ _callbackFunction = ctx[CpuRegister.Rdi];
+
+ // EXPERIMENT: failing ONLY the callback registration (with a generic kernel
+ // error, unlike the BT-specific unavailable code) may make wheel/FFB
+ // middleware disable its Bluetooth search loop instead of polling forever.
+ // SHARPEMU_BTHID_CB_FAIL=nf -> NOT_FOUND, =ni -> NOT_IMPLEMENTED.
+ var mode = Environment.GetEnvironmentVariable("SHARPEMU_BTHID_CB_FAIL");
+ if (string.Equals(mode, "nf", StringComparison.OrdinalIgnoreCase))
+ {
+ return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
+ }
+
+ if (string.Equals(mode, "ni", StringComparison.OrdinalIgnoreCase))
+ {
+ return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED);
+ }
+
+ return Result(ctx);
+ }
+
+ private static void FireEnumerationComplete(CpuContext ctx)
+ {
+ var scheduler = GuestThreadExecution.Scheduler;
+ if (scheduler is null)
+ {
+ return;
+ }
+
+ var eventCode = ParseEnvUInt64("SHARPEMU_BTHID_EVENT_CODE", 0);
+ var eventSize = (int)ParseEnvUInt64("SHARPEMU_BTHID_EVENT_SIZE", 256);
+ if (eventSize < 1)
+ {
+ eventSize = 256;
+ }
+
+ // Leaked by design: the guest may retain the pointer past the callback.
+ var eventStruct = Marshal.AllocHGlobal(eventSize);
+ for (var offset = 0; offset < eventSize; offset++)
+ {
+ Marshal.WriteByte(eventStruct, offset, 0);
+ }
+
+ Console.Error.WriteLine(
+ $"[BTHID][EXPERIMENT] firing callback=0x{_callbackFunction:X} code={eventCode} size={eventSize} struct=0x{eventStruct.ToInt64():X}");
+ if (!scheduler.TryCallGuestFunction(
+ ctx,
+ _callbackFunction,
+ unchecked((ulong)eventStruct.ToInt64()),
+ eventCode,
+ 0,
+ 0,
+ "sceBluetoothHid synthetic enumeration event",
+ out var error))
+ {
+ Console.Error.WriteLine($"[BTHID][EXPERIMENT] callback failed: {error}");
+ }
+ else
+ {
+ Console.Error.WriteLine("[BTHID][EXPERIMENT] callback returned OK");
+ }
+ }
+
+ private static ulong ParseEnvUInt64(string name, ulong fallback)
+ {
+ var value = Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return fallback;
+ }
+
+ value = value.Trim();
+ var hex = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase);
+ return ulong.TryParse(
+ hex ? value[2..] : value,
+ hex ? System.Globalization.NumberStyles.HexNumber : System.Globalization.NumberStyles.Integer,
+ null,
+ out var parsed) ? parsed : fallback;
+ }
}
diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs
index f5253ac8..c1a62a68 100644
--- a/src/SharpEmu.Libs/Pad/PadExports.cs
+++ b/src/SharpEmu.Libs/Pad/PadExports.cs
@@ -23,6 +23,11 @@ public static class PadExports
private const int PrimaryPadHandle = 1;
private const int ControllerInformationSize = 0x1C;
private const int PadDataSize = 0x78;
+
+ // Real firmware hands out small non-negative handles; 0 is valid. Some titles
+ // (Monster Truck Championship) read pad state with handle 0, and rejecting it
+ // leaves their controller/FFB init path polling a never-valid state forever.
+ private static bool IsPrimaryPadHandle(int handle) => handle is 0 or PrimaryPadHandle;
private static readonly long InputSampleIntervalTicks = Math.Max(1, Stopwatch.Frequency / 1000);
[ThreadStatic]
@@ -104,7 +109,7 @@ public static class PadExports
public static int PadClose(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
- return handle == PrimaryPadHandle
+ return IsPrimaryPadHandle(handle)
? ctx.SetReturn(0)
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -117,7 +122,7 @@ public static class PadExports
public static int PadSetMotionSensorState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
- return handle == PrimaryPadHandle
+ return IsPrimaryPadHandle(handle)
? ctx.SetReturn(0)
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -131,7 +136,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var informationAddress = ctx[CpuRegister.Rsi];
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -166,7 +171,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var informationAddress = ctx[CpuRegister.Rsi];
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -207,7 +212,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var dataAddress = ctx[CpuRegister.Rsi];
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -232,7 +237,7 @@ public static class PadExports
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var dataAddress = ctx[CpuRegister.Rsi];
var count = unchecked((int)ctx[CpuRegister.Rdx]);
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -266,7 +271,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -310,7 +315,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -340,7 +345,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -369,7 +374,7 @@ public static class PadExports
public static int PadResetLightBar(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
- if (handle != PrimaryPadHandle)
+ if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
diff --git a/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs b/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs
index b4f68f85..9a8644d4 100644
--- a/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs
+++ b/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs
@@ -418,12 +418,18 @@ public static class PlayGoExports
$"[LOADER][TRACE] playgo.unknown_chunk_id id={chunkId} entries={numberOfEntries} " +
$"known=[{string.Join(',', knownChunkIds)}]");
}
+
+ // Real firmware rejects chunk ids outside the package's chunk set.
+ // Titles rely on this as an enumeration terminator: Monster Truck
+ // scans ids 0,1,2,... until BAD_CHUNK_ID, and answering OK for every
+ // id makes that scan wrap the ushort range and spin forever.
+ return OrbisPlayGoErrorBadChunkId;
}
loci[i] = PlayGoLocusLocalFast;
}
- TracePlayGoLocus(numberOfEntries, chunkIds, outLoci);
+ TracePlayGoLocus(ctx, numberOfEntries, chunkIds, outLoci);
return ctx.Memory.TryWrite(outLoci, loci)
? (int)OrbisGen2Result.ORBIS_GEN2_OK
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
@@ -663,7 +669,7 @@ public static class PlayGoExports
{
lock (_stateGate)
{
- return _metadata.ChunkIds.Length == 0 || Array.BinarySearch(_metadata.ChunkIds, chunkId) >= 0;
+ return Array.BinarySearch(_metadata.ChunkIds, chunkId) >= 0;
}
}
@@ -672,7 +678,9 @@ public static class PlayGoExports
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
if (string.IsNullOrWhiteSpace(app0Root))
{
- return PlayGoMetadata.Empty;
+ // No app0 override to probe for sidecar files: same fully-installed
+ // single-chunk fallback as below, or scePlayGoOpen fails fatally.
+ return new PlayGoMetadata(true, [(ushort)0]);
}
var playGoDat = Path.Combine(app0Root, "sce_sys", "playgo-chunk.dat");
@@ -682,12 +690,23 @@ public static class PlayGoExports
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
if (!hasMetadata)
{
- // Full installs may omit PlayGo sidecar metadata.
- TracePlayGo("metadata_missing; using fully-installed default chunk");
+ // No PlayGo sidecar: report a fully-installed single chunk. Available must
+ // stay true or scePlayGoOpen fails with NotSupportPlayGo (fatal PS5-component
+ // init failure for UE titles); chunk 0 reports LocalFast and every other id
+ // returns BAD_CHUNK_ID, terminating title-side chunk enumeration.
+ TracePlayGo("metadata_missing; fully-installed single chunk");
return new PlayGoMetadata(true, [(ushort)0]);
}
var chunkIds = LoadChunkIds(chunkDefsXml);
+ if (chunkIds.Length == 0)
+ {
+ // Unreadable/empty sidecar: fall back to chunk 0, not an empty set
+ // (which IsKnownChunkId would treat as "every id valid").
+ TracePlayGo("metadata_unreadable; fully-installed single chunk");
+ return new PlayGoMetadata(true, [(ushort)0]);
+ }
+
return new PlayGoMetadata(true, chunkIds);
}
@@ -738,7 +757,7 @@ public static class PlayGoExports
}
}
- private static void TracePlayGoLocus(uint entries, ulong chunkIds, ulong outLoci)
+ private static void TracePlayGoLocus(CpuContext ctx, uint entries, ulong chunkIds, ulong outLoci)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PLAYGO"), "1", StringComparison.Ordinal))
{
@@ -748,8 +767,10 @@ public static class PlayGoExports
var count = Interlocked.Increment(ref _locusTraceDiagnostics);
if (entries != 1 || count <= 32 || count % 1000 == 0)
{
+ _ = ctx.TryReadUInt16(chunkIds, out var firstChunkId);
Console.Error.WriteLine(
- $"[LOADER][TRACE] playgo.get_locus entries={entries} chunk_ids=0x{chunkIds:X16} out=0x{outLoci:X16}");
+ $"[LOADER][TRACE] playgo.get_locus entries={entries} first_chunk={firstChunkId} " +
+ $"chunk_ids=0x{chunkIds:X16} out=0x{outLoci:X16}");
}
}
diff --git a/src/SharpEmu.Libs/Rtc/RtcExports.cs b/src/SharpEmu.Libs/Rtc/RtcExports.cs
index 709526fd..f05cb1f7 100644
--- a/src/SharpEmu.Libs/Rtc/RtcExports.cs
+++ b/src/SharpEmu.Libs/Rtc/RtcExports.cs
@@ -241,6 +241,64 @@ public static class RtcExports
LibraryName = "libSceRtc")]
public static int RtcGetCurrentRawNetworkTick(CpuContext ctx) => RtcGetCurrentTick(ctx);
+ // Diagnostic: middleware busy-wait loops typically poll sceRtcGetCurrentTick, so the
+ // caller's return address pinpoints the loop. SHARPEMU_RTC_PROBE_RANGE=-
+ // (hex guest addresses) dumps 0x100 bytes of code around the first matching caller,
+ // once, for offline disassembly. Costs nothing when the variable is unset.
+ private static readonly ulong[]? _rtcProbeRange = ParseRtcProbeRange();
+ private static int _rtcProbeDone;
+
+ private static ulong[]? ParseRtcProbeRange()
+ {
+ var value = Environment.GetEnvironmentVariable("SHARPEMU_RTC_PROBE_RANGE");
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ var parts = value.Split('-', 2, StringSplitOptions.TrimEntries);
+ return parts.Length == 2 &&
+ TryParseHexAddress(parts[0], out var start) &&
+ TryParseHexAddress(parts[1], out var end) &&
+ start < end
+ ? [start, end]
+ : null;
+ }
+
+ private static bool TryParseHexAddress(string value, out ulong address)
+ {
+ if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ {
+ value = value[2..];
+ }
+
+ return ulong.TryParse(
+ value,
+ System.Globalization.NumberStyles.HexNumber,
+ null,
+ out address);
+ }
+
+ private static void ProbeRtcCaller(CpuContext ctx)
+ {
+ if (Volatile.Read(ref _rtcProbeDone) != 0 ||
+ !ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var ret) ||
+ ret < _rtcProbeRange![0] ||
+ ret >= _rtcProbeRange[1] ||
+ Interlocked.CompareExchange(ref _rtcProbeDone, 1, 0) != 0)
+ {
+ return;
+ }
+
+ var start = ret - 0x60;
+ Span code = stackalloc byte[0x100];
+ if (ctx.Memory.TryRead(start, code))
+ {
+ Console.Error.WriteLine(
+ $"[LOADER][DIAG] rtc.caller_code ret=0x{ret:X} @0x{start:X}: {System.Convert.ToHexString(code)}");
+ }
+ }
+
[SysAbiExport(
Nid = "18B2NS1y9UU",
ExportName = "sceRtcGetCurrentTick",
@@ -254,6 +312,11 @@ public static class RtcExports
return unchecked((int)0x80B50002);
}
+ if (_rtcProbeRange is not null)
+ {
+ ProbeRtcCaller(ctx);
+ }
+
var tickValue = unchecked((ulong)(DateTime.UtcNow.Ticks / DateTimeTicksPerMicrosecond));
if (!ctx.TryWriteUInt64(tickAddress, tickValue))
{
diff --git a/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs b/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs
index d0a35b0b..260a3c65 100644
--- a/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs
+++ b/src/SharpEmu.Libs/SystemService/SystemServiceExports.cs
@@ -15,6 +15,62 @@ public static class SystemServiceExports
private const int DisplaySafeAreaInfoSize = sizeof(float) + 128;
private const int HdrToneMapLuminanceSize = sizeof(float) * 3;
+ private const int TitleIdFieldSize = 0x10;
+
+ private static string? _mainAppTitleId;
+
+ public static void ConfigureApplicationInfo(string? titleId)
+ {
+ _mainAppTitleId = string.IsNullOrWhiteSpace(titleId) ? null : titleId.Trim();
+ }
+
+ [SysAbiExport(
+ Nid = "3RQ5aQfnstU",
+ ExportName = "sceSystemServiceGetNoticeScreenSkipFlag",
+ Target = Generation.Gen5,
+ LibraryName = "libSceSystemService")]
+ public static int SystemServiceGetNoticeScreenSkipFlag(CpuContext ctx)
+ {
+ var flagAddress = ctx[CpuRegister.Rdi];
+ if (flagAddress == 0)
+ {
+ return ctx.SetReturn(OrbisSystemServiceErrorParameter);
+ }
+
+ // No system notice screen to skip in the emulator; report "do not skip".
+ Span flagBytes = stackalloc byte[sizeof(int)];
+ BinaryPrimitives.WriteInt32LittleEndian(flagBytes, 0);
+ return ctx.Memory.TryWrite(flagAddress, flagBytes)
+ ? ctx.SetReturn(0)
+ : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ [SysAbiExport(
+ Nid = "4veE0XiIugA",
+ ExportName = "sceSystemServiceGetMainAppTitleId",
+ Target = Generation.Gen5,
+ LibraryName = "libSceSystemService")]
+ public static int SystemServiceGetMainAppTitleId(CpuContext ctx)
+ {
+ var titleIdAddress = ctx[CpuRegister.Rdi];
+ if (titleIdAddress == 0)
+ {
+ return ctx.SetReturn(OrbisSystemServiceErrorParameter);
+ }
+
+ // Title IDs are a fixed 9-char format written into a 0x10-byte field;
+ // bound the length so a malformed param.json cannot drive an unbounded
+ // stack allocation or overrun the guest buffer.
+ var titleId = _mainAppTitleId ?? "PPSA00000";
+ var length = Math.Min(titleId.Length, TitleIdFieldSize - 1);
+ Span titleIdBytes = stackalloc byte[TitleIdFieldSize];
+ titleIdBytes.Clear();
+ System.Text.Encoding.ASCII.GetBytes(titleId.AsSpan(0, length), titleIdBytes);
+ return ctx.Memory.TryWrite(titleIdAddress, titleIdBytes[..(length + 1)])
+ ? ctx.SetReturn(0)
+ : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
[SysAbiExport(
Nid = "fZo48un7LK4",
ExportName = "sceSystemServiceParamGetInt",
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index a896c417..2b1574f2 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -4160,6 +4160,48 @@ internal static unsafe class VulkanVideoPresenter
_submitTimeline;
}
+ private void TransitionNewGuestImageToSampled(Image image, uint mipLevels)
+ {
+ var commandBuffer = AllocateGuestCommandBuffer();
+ var beginInfo = new CommandBufferBeginInfo
+ {
+ SType = StructureType.CommandBufferBeginInfo,
+ Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
+ };
+ Check(
+ _vk.BeginCommandBuffer(commandBuffer, &beginInfo),
+ "vkBeginCommandBuffer(guest image init)");
+ var barrier = new ImageMemoryBarrier
+ {
+ SType = StructureType.ImageMemoryBarrier,
+ SrcAccessMask = 0,
+ DstAccessMask = AccessFlags.ShaderReadBit,
+ OldLayout = ImageLayout.Undefined,
+ NewLayout = ImageLayout.ShaderReadOnlyOptimal,
+ SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
+ DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
+ Image = image,
+ SubresourceRange = ColorSubresourceRange(0, mipLevels),
+ };
+ _vk.CmdPipelineBarrier(
+ commandBuffer,
+ PipelineStageFlags.TopOfPipeBit,
+ PipelineStageFlags.AllCommandsBit,
+ 0,
+ 0,
+ null,
+ 0,
+ null,
+ 1,
+ &barrier);
+ Check(
+ _vk.EndCommandBuffer(commandBuffer),
+ "vkEndCommandBuffer(guest image init)");
+ // Same-queue submission order makes the transition visible to any
+ // later use of the image; no CPU-side wait is needed.
+ SubmitGuestCommandBuffer(commandBuffer, [], []);
+ }
+
private void EnsureGuestSubmissionCapacity()
{
CollectCompletedGuestSubmissions(waitForOldest: false);
@@ -4220,23 +4262,43 @@ internal static unsafe class VulkanVideoPresenter
return;
}
- Check(result, $"vkWaitForFences(guest: {oldest.DebugName})");
+ if (result == Result.ErrorDeviceLost)
+ {
+ _deviceLost = true;
+ }
+ else
+ {
+ Check(result, $"vkWaitForFences(guest: {oldest.DebugName})");
+ }
}
while (_pendingGuestSubmissions.TryPeek(out var submission))
{
var status = _vk.GetFenceStatus(_device, submission.Fence);
- if (status == Result.NotReady)
+ if (status == Result.NotReady && !_deviceLost)
{
break;
}
- Check(status, $"vkGetFenceStatus(guest: {submission.DebugName})");
+ if (status == Result.ErrorDeviceLost)
+ {
+ // Pending fences never signal on a lost device; retire the
+ // submission anyway so teardown and back-pressure survive.
+ _deviceLost = true;
+ }
+ else if (status != Result.NotReady)
+ {
+ Check(status, $"vkGetFenceStatus(guest: {submission.DebugName})");
+ }
+
_pendingGuestSubmissions.Dequeue();
- foreach (var image in submission.TraceImages)
+ if (!_deviceLost)
{
- TraceGuestImageContents(image);
+ foreach (var image in submission.TraceImages)
+ {
+ TraceGuestImageContents(image);
+ }
}
foreach (var resources in submission.Resources)
@@ -4514,13 +4576,20 @@ internal static unsafe class VulkanVideoPresenter
$"queue={_activeGuestQueue.Name} submission={_activeGuestQueue.SubmissionId} " +
$"handle={work.VideoOutHandle} index={work.DisplayBufferIndex} " +
$"capture_complete={(captured ? 1 : 0)}");
-#if DEBUG
- System.Diagnostics.Debug.Assert(
- work.Version == 0 || captured,
- "An ordered wait-safe marker must execute after its flip capture.");
-#endif
+ // Demon's Souls executes wait-safe markers before their flip capture;
+ // an assert here would fail-fast the process, so warn once instead.
+ // Dedup on a flag, not the (per-frame-unique) version, to bound growth.
+ if (work.Version != 0 && !captured && !_loggedFlipWaitOrderViolation)
+ {
+ _loggedFlipWaitOrderViolation = true;
+ Console.Error.WriteLine(
+ $"[LOADER][WARN] vk.flip_wait_order version={work.Version} " +
+ "executed before its flip capture; continuing.");
+ }
}
+ private bool _loggedFlipWaitOrderViolation;
+
private GuestImageResource CreateGuestFlipSnapshot(
GuestImageResource source,
long version)
@@ -8300,7 +8369,7 @@ internal static unsafe class VulkanVideoPresenter
return (properties.OptimalTilingFeatures & FormatFeatureFlags.ColorAttachmentBit) != 0;
}
- private static Format GetTextureFormat(uint format, uint numberType) =>
+ internal static Format GetTextureFormat(uint format, uint numberType) =>
(format, numberType) switch
{
(9, _) => Format.A2B10G10R10UnormPack32,
@@ -10170,6 +10239,9 @@ internal static unsafe class VulkanVideoPresenter
_vk.AllocateMemory(_device, &allocationInfo, null, out var memory),
"vkAllocateMemory(offscreen)");
Check(_vk.BindImageMemory(_device, image, memory, 0), "vkBindImageMemory(offscreen)");
+ // Rendering and uploads only define the mips they touch; define the whole
+ // chain once so full-chain sampled binds never read Undefined layout.
+ TransitionNewGuestImageToSampled(image, mipLevels);
var viewInfo = new ImageViewCreateInfo
{
@@ -10937,7 +11009,7 @@ internal static unsafe class VulkanVideoPresenter
return view;
}
- private static bool IsCompatibleViewFormat(Format imageFormat, Format viewFormat)
+ internal static bool IsCompatibleViewFormat(Format imageFormat, Format viewFormat)
{
if (imageFormat == viewFormat)
{
@@ -11003,6 +11075,23 @@ internal static unsafe class VulkanVideoPresenter
}
private void Render(double _)
+ {
+ try
+ {
+ RenderCore();
+ }
+ catch (Exception exception)
+ {
+ // Device loss can strike between any two Vulkan calls in the frame;
+ // keep the window loop pumping instead of tearing the presenter down.
+ if (!TryMarkDeviceLost(exception))
+ {
+ throw;
+ }
+ }
+ }
+
+ private void RenderCore()
{
if (Volatile.Read(ref _presenterCloseRequested))
{
@@ -11016,6 +11105,18 @@ internal static unsafe class VulkanVideoPresenter
return;
}
+ if (_deviceLost)
+ {
+ // Drain queued work so producers aren't back-pressured, then
+ // return without any Vulkan call (fences never signal post-loss).
+ while (TryTakeGuestWork(out var lostWork))
+ {
+ CompleteGuestWork(lostWork);
+ }
+
+ return;
+ }
+
// Reuse of a frame slot waits only on that slot's fence, keeping
// up to MaxFramesInFlight frames pipelined between CPU and GPU.
var frameSlot = _currentFrameSlot;
@@ -13836,20 +13937,38 @@ internal static unsafe class VulkanVideoPresenter
return;
}
+ if (result == Result.ErrorDeviceLost)
+ {
+ throw new VulkanDeviceLostException(operation);
+ }
+
throw new InvalidOperationException($"{operation} failed with {result}.");
}
private static void Check(Result result, string operation)
{
+ if (result == Result.ErrorDeviceLost)
+ {
+ throw new VulkanDeviceLostException(operation);
+ }
+
if (result != Result.Success)
{
throw new InvalidOperationException($"{operation} failed with {result}.");
}
}
+ // Typed so the frame-boundary catch can recognize device loss without
+ // depending on the exact wording of the exception message.
+ private sealed class VulkanDeviceLostException(string operation)
+ : InvalidOperationException($"{operation} failed with {Result.ErrorDeviceLost}.");
+
private bool TryMarkDeviceLost(Exception exception)
{
- if (!exception.Message.Contains(nameof(Result.ErrorDeviceLost), StringComparison.Ordinal))
+ // Prefer the typed signal; fall back to the message for losses that
+ // surface through other layers (e.g. Silk.NET bindings).
+ if (exception is not VulkanDeviceLostException &&
+ !exception.Message.Contains(nameof(Result.ErrorDeviceLost), StringComparison.Ordinal))
{
return false;
}
diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs
index 688bc841..8c740da6 100644
--- a/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs
+++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderTranslator.cs
@@ -1767,9 +1767,9 @@ public static class Gen5ShaderTranslator
destinations = [Gen5Operand.Vector(word & 0xFF)];
if (opcode == "VReadlaneB32")
{
- // VReadlaneB32 writes to scalar destination (bits 8-14), not vector.
- // Bits 0-7 are unused for this opcode.
- destinations = [Gen5Operand.Scalar((word >> 8) & 0x7F)];
+ // The scalar destination lives in the low vdst byte (bits 0-7);
+ // bits 8-14 are the VOP3B carry-out sdst, which readlane lacks.
+ destinations = [Gen5Operand.Scalar(word & 0xFF)];
}
var isVop3B = IsVop3BOpcode((word >> 16) & 0x3FF);
control = new Gen5Vop3Control(
diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs
new file mode 100644
index 00000000..3d0ac43f
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs
@@ -0,0 +1,55 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.Libs.Kernel;
+using System.Text;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Kernel;
+
+public sealed class KernelMemoryCompatExportsTests
+{
+ [Fact]
+ public void PosixStat_MissingFileReturnsMinusOne()
+ {
+ const ulong memoryBase = 0x1_0000_0000;
+ const ulong pathAddress = memoryBase + 0x100;
+ const ulong statAddress = memoryBase + 0x400;
+ var memory = new FakeCpuMemory(memoryBase, 0x1000);
+ var context = new CpuContext(memory, Generation.Gen5);
+ memory.WriteCString(pathAddress, "/__sharpemu_test_missing__/shader.cache");
+ context[CpuRegister.Rdi] = pathAddress;
+ context[CpuRegister.Rsi] = statAddress;
+
+ var result = KernelMemoryCompatExports.PosixStat(context);
+
+ Assert.Equal(-1, result);
+ Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
+ }
+
+ [Fact]
+ public void Sprintf_ReadsVariadicDoubleFromXmmRegister()
+ {
+ const ulong memoryBase = 0x1_0000_0000;
+ const ulong destinationAddress = memoryBase + 0x100;
+ const ulong formatAddress = memoryBase + 0x200;
+ var memory = new FakeCpuMemory(memoryBase, 0x1000);
+ var context = new CpuContext(memory, Generation.Gen5);
+ memory.WriteCString(formatAddress, "%.4f");
+ context[CpuRegister.Rdi] = destinationAddress;
+ context[CpuRegister.Rsi] = formatAddress;
+ context.SetXmmRegister(
+ 0,
+ unchecked((ulong)BitConverter.DoubleToInt64Bits(0.5576)),
+ 0);
+
+ var result = KernelMemoryCompatExports.Sprintf(context);
+
+ Assert.Equal(0, result);
+ Assert.Equal(6UL, context[CpuRegister.Rax]);
+ Span output = stackalloc byte[7];
+ Assert.True(memory.TryRead(destinationAddress, output));
+ Assert.Equal("0.5576\0", Encoding.UTF8.GetString(output));
+ }
+}