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)); + } +}