diff --git a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs index 9d061045..cf470dc9 100644 --- a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs +++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs @@ -3,6 +3,7 @@ using SharpEmu.HLE; using SharpEmu.Libs.Kernel; +using System.Buffers; using System.Buffers.Binary; using System.Text; @@ -14,6 +15,7 @@ public static class SaveDataExports private const int OrbisSaveDataErrorExists = unchecked((int)0x809F0007); private const int OrbisSaveDataErrorNotFound = unchecked((int)0x809F0008); private const int OrbisSaveDataErrorInternal = unchecked((int)0x809F000B); + private const int OrbisSaveDataErrorMemoryNotReady = unchecked((int)0x809F0012); private const int SaveDataTitleIdSize = 10; private const int SaveDataDirNameSize = 32; private const int SaveDataParamSize = 0x530; @@ -29,7 +31,10 @@ public static class SaveDataExports private const uint MountModeCreate = 1u << 2; private const uint MountModeCreate2 = 1u << 5; private const int MountResultSize = 0x40; + // Emulator guard against corrupt or misread sizes, not a platform limit. + private const ulong SaveDataMemoryMaxSize = 64UL * 1024 * 1024; private static readonly object _stateGate = new(); + private static readonly object _memoryGate = new(); private static readonly HashSet _preparedTransactionResources = []; private static string? _titleId; @@ -472,6 +477,19 @@ public static class SaveDataExports private static string ResolveTitleSaveRoot(int userId, string titleId) => Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId)); + private static string ResolveSaveDataMemoryPath(int userId) => + Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat"); + + private static bool TryReadMemoryData( + CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset) + { + size = 0; + offset = 0; + return ctx.TryReadUInt64(address, out buffer) && + ctx.TryReadUInt64(address + 0x08, out size) && + ctx.TryReadUInt64(address + 0x10, out offset); + } + private static string ResolveSaveDataRoot() { var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR"); @@ -670,4 +688,197 @@ public static class SaveDataExports TraceSaveData($"commit commit=0x{commitAddress:X16}"); return ctx.SetReturn(0); } + + // Save data memory: a small per-user blob titles read and write without + // mounting anything, backed by one zero-filled file per user and title. + [SysAbiExport( + Nid = "oQySEUfgXRA", + ExportName = "sceSaveDataSetupSaveDataMemory2", + Target = Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataSetupSaveDataMemory2(CpuContext ctx) + { + var paramAddress = ctx[CpuRegister.Rdi]; + var resultAddress = ctx[CpuRegister.Rsi]; + if (paramAddress == 0) + { + return ctx.SetReturn(OrbisSaveDataErrorParameter); + } + + if (!TryReadInt32(ctx, paramAddress + 0x04, out var userId) || + !ctx.TryReadUInt64(paramAddress + 0x08, out var memorySize)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (userId < 0 || memorySize == 0 || memorySize > SaveDataMemoryMaxSize) + { + return ctx.SetReturn(OrbisSaveDataErrorParameter); + } + + try + { + var path = ResolveSaveDataMemoryPath(userId); + lock (_memoryGate) + { + var backing = new FileInfo(path); + var existedSize = backing.Exists ? (ulong)backing.Length : 0; + + // The result write comes first so a faulted result pointer + // cannot leave created or grown setup state behind. + if (resultAddress != 0 && !ctx.TryWriteUInt64(resultAddress, existedSize)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (existedSize < memorySize) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite); + stream.SetLength((long)memorySize); + } + + TraceSaveData($"memory-setup2 user={userId} size=0x{memorySize:X} existed=0x{existedSize:X}"); + } + + return ctx.SetReturn(0); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return ctx.SetReturn(OrbisSaveDataErrorInternal); + } + } + + [SysAbiExport( + Nid = "QwOO7vegnV8", + ExportName = "sceSaveDataGetSaveDataMemory2", + Target = Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataGetSaveDataMemory2(CpuContext ctx) => + TransferSaveDataMemory(ctx, write: false); + + [SysAbiExport( + Nid = "cduy9v4YmT4", + ExportName = "sceSaveDataSetSaveDataMemory2", + Target = Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataSetSaveDataMemory2(CpuContext ctx) => + TransferSaveDataMemory(ctx, write: true); + + // Writes go straight through to the backing file, so a ready state is + // all sync has to confirm. + [SysAbiExport( + Nid = "wiT9jeC7xPw", + ExportName = "sceSaveDataSyncSaveDataMemory", + Target = Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataSyncSaveDataMemory(CpuContext ctx) + { + var syncAddress = ctx[CpuRegister.Rdi]; + if (syncAddress == 0) + { + return ctx.SetReturn(OrbisSaveDataErrorParameter); + } + + if (!TryReadInt32(ctx, syncAddress, out var userId)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (userId < 0) + { + return ctx.SetReturn(OrbisSaveDataErrorParameter); + } + + return ctx.SetReturn( + File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady); + } + + private static int TransferSaveDataMemory(CpuContext ctx, bool write) + { + var requestAddress = ctx[CpuRegister.Rdi]; + if (requestAddress == 0) + { + return ctx.SetReturn(OrbisSaveDataErrorParameter); + } + + if (!TryReadInt32(ctx, requestAddress, out var userId) || + !ctx.TryReadUInt64(requestAddress + 0x08, out var dataAddress)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (userId < 0) + { + return ctx.SetReturn(OrbisSaveDataErrorParameter); + } + + try + { + var path = ResolveSaveDataMemoryPath(userId); + lock (_memoryGate) + { + if (!File.Exists(path)) + { + return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady); + } + + if (dataAddress == 0) + { + return ctx.SetReturn(0); + } + + if (!TryReadMemoryData(ctx, dataAddress, out var bufAddress, out var bufSize, out var offset)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + using var stream = new FileStream( + path, FileMode.Open, write ? FileAccess.ReadWrite : FileAccess.Read); + var length = (ulong)stream.Length; + if (bufAddress == 0 || bufSize > length || offset > length - bufSize) + { + return ctx.SetReturn(OrbisSaveDataErrorParameter); + } + + // The guarded file length bounds bufSize, so one rented buffer + // covers the transfer and a guest fault never partially writes. + var buffer = ArrayPool.Shared.Rent((int)Math.Max(bufSize, 1)); + try + { + var span = buffer.AsSpan(0, (int)bufSize); + stream.Seek((long)offset, SeekOrigin.Begin); + if (write) + { + if (!ctx.Memory.TryRead(bufAddress, span)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + stream.Write(span); + } + else + { + stream.ReadExactly(span); + if (!ctx.Memory.TryWrite(bufAddress, span)) + { + return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + TraceSaveData( + $"memory-{(write ? "set2" : "get2")} user={userId} offset=0x{offset:X} size=0x{bufSize:X}"); + return ctx.SetReturn(0); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return ctx.SetReturn(OrbisSaveDataErrorInternal); + } + } } diff --git a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs new file mode 100644 index 00000000..11924a7f --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataMemoryExportsTests.cs @@ -0,0 +1,256 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.SaveData; +using Xunit; + +namespace SharpEmu.Libs.Tests.SaveData; + +[CollectionDefinition("SaveDataMemoryState", DisableParallelization = true)] +public sealed class SaveDataMemoryStateCollection; + +// The save data memory exports persist to a real backing file whose resolved +// path depends on process-wide environment and title configuration. The +// fixture pins both and the collection keeps other environment-mutating tests +// from running alongside. +[Collection("SaveDataMemoryState")] +public sealed class SaveDataMemoryExportsTests : IDisposable +{ + private const ulong Base = 0x1_0000_0000; + private const ulong SetupParamAddress = Base + 0x100; + private const ulong SetupResultAddress = Base + 0x180; + private const ulong DataStructAddress = Base + 0x200; + private const ulong RequestAddress = Base + 0x280; + private const ulong SyncParamAddress = Base + 0x300; + private const ulong PayloadAddress = Base + 0x400; + private const ulong ReadbackAddress = Base + 0x800; + private const ulong LargePayloadAddress = Base + 0x1000; + private const ulong LargeReadbackAddress = Base + 0x40000; + private const ulong MemorySize = 0x2000; + private const ulong UnmappedAddress = Base + 0x100000; + private const int MemoryNotReady = unchecked((int)0x809F0012); + private const int ParameterError = unchecked((int)0x809F0000); + private const int UserId = 0x1001; + private const string TitleId = "SDMEMTEST"; + + private readonly FakeCpuMemory _memory = new(Base, 0x80000); + private readonly CpuContext _ctx; + private readonly string _root; + private readonly string? _previousRoot; + + public SaveDataMemoryExportsTests() + { + _ctx = new CpuContext(_memory, Generation.Gen5); + _root = Path.Combine(Path.GetTempPath(), $"sharpemu-sdmemory-{Guid.NewGuid():N}"); + _previousRoot = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR"); + Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _root); + SaveDataExports.ConfigureApplicationInfo(TitleId); + } + + private string MemoryPath => + Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat"); + + public void Dispose() + { + SaveDataExports.ConfigureApplicationInfo(null); + Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _previousRoot); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void Setup_ReportsExistingSizeOnSecondCall() + { + Assert.Equal(0, Setup()); + Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize)); + Assert.Equal(0ul, existedSize); + + Assert.Equal(0, Setup()); + Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out existedSize)); + Assert.Equal(MemorySize, existedSize); + } + + [Fact] + public void SetThenGet_RoundTripsThroughBackingFile() + { + Assert.Equal(0, Setup()); + + var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload)); + WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0x40); + Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke())); + + WriteRequest(ReadbackAddress, (ulong)payload.Length, offset: 0x40); + Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke())); + var readback = new byte[payload.Length]; + Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback)); + Assert.Equal(payload, readback); + } + + [Fact] + public void SetThenGet_LargePayload_RoundTrips() + { + const ulong size = 0x30000; + Assert.Equal(0, Setup(0x40000)); + + var payload = new byte[size]; + for (var i = 0; i < payload.Length; i++) + { + payload[i] = (byte)(i * 31 + 7); + } + + Assert.True(_ctx.Memory.TryWrite(LargePayloadAddress, payload)); + WriteRequest(LargePayloadAddress, size, offset: 0x100); + Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke())); + + WriteRequest(LargeReadbackAddress, size, offset: 0x100); + Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke())); + var readback = new byte[size]; + Assert.True(_ctx.Memory.TryRead(LargeReadbackAddress, readback)); + Assert.Equal(payload, readback); + } + + [Fact] + public void Setup_AbsurdSize_ReturnsParameterError() + { + Assert.Equal(ParameterError, Setup(ulong.MaxValue)); + } + + [Fact] + public void Setup_InvalidResultPointer_DoesNotCreateBackingFile() + { + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, + Setup(resultAddress: UnmappedAddress)); + Assert.False(File.Exists(MemoryPath)); + } + + [Fact] + public void Setup_InvalidResultPointer_DoesNotGrowExistingFile() + { + Assert.Equal(0, Setup(0x1000)); + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, + Setup(MemorySize, UnmappedAddress)); + Assert.Equal(0x1000, new FileInfo(MemoryPath).Length); + } + + [Fact] + public void Setup_GrowingExistingMemory_ZeroExtendsAndPreservesContent() + { + Assert.Equal(0, Setup(0x1000)); + var payload = new byte[] { 0x11, 0x22 }; + Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload)); + WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0xFF0); + Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke())); + + Assert.Equal(0, Setup(MemorySize)); + Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize)); + Assert.Equal(0x1000ul, existedSize); + + WriteRequest(ReadbackAddress, 0x20, offset: 0xFF0); + Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke())); + var readback = new byte[0x20]; + Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback)); + Assert.Equal(payload, readback[..2]); + Assert.All(readback[2..], b => Assert.Equal(0, b)); + } + + [Fact] + public void GetSetSync_BeforeSetup_ReturnMemoryNotReady() + { + WriteRequest(ReadbackAddress, 0x10, offset: 0); + Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke())); + Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke())); + Assert.Equal(MemoryNotReady, Sync()); + } + + [Fact] + public void GetAndSync_AfterSetup_Succeed() + { + Assert.Equal(0, Setup()); + WriteRequest(ReadbackAddress, 0x10, offset: 0); + Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke())); + Assert.Equal(0, Sync()); + } + + [Fact] + public void Get_BackingFileRemovedAfterSetup_ReturnsMemoryNotReady() + { + Assert.Equal(0, Setup()); + Directory.Delete(_root, recursive: true); + WriteRequest(ReadbackAddress, 0x10, offset: 0); + Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke())); + } + + [Theory] + [InlineData(MemorySize, 1ul)] + [InlineData(0ul, MemorySize + 1)] + [InlineData(ulong.MaxValue, 0x10ul)] + public void Get_OutOfRange_ReturnsParameterError(ulong offset, ulong size) + { + Assert.Equal(0, Setup()); + WriteRequest(ReadbackAddress, size, offset); + Assert.Equal(ParameterError, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke())); + } + + [Theory] + [InlineData(MemorySize, 1ul)] + [InlineData(0ul, MemorySize + 1)] + [InlineData(ulong.MaxValue, 0x10ul)] + public void Set_OutOfRange_ReturnsParameterError(ulong offset, ulong size) + { + Assert.Equal(0, Setup()); + WriteRequest(PayloadAddress, size, offset); + Assert.Equal(ParameterError, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke())); + } + + private int Setup(ulong memorySize = MemorySize, ulong resultAddress = SetupResultAddress) + { + Span param = stackalloc byte[0x40]; + param.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(param[0x04..], UserId); + BinaryPrimitives.WriteUInt64LittleEndian(param[0x08..], memorySize); + Assert.True(_ctx.Memory.TryWrite(SetupParamAddress, param)); + + _ctx[CpuRegister.Rdi] = SetupParamAddress; + _ctx[CpuRegister.Rsi] = resultAddress; + return SaveDataExports.SaveDataSetupSaveDataMemory2(_ctx); + } + + private int Sync() + { + Span param = stackalloc byte[0x28]; + param.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(param, UserId); + Assert.True(_ctx.Memory.TryWrite(SyncParamAddress, param)); + + _ctx[CpuRegister.Rdi] = SyncParamAddress; + return SaveDataExports.SaveDataSyncSaveDataMemory(_ctx); + } + + private void WriteRequest(ulong bufAddress, ulong bufSize, ulong offset) + { + Span data = stackalloc byte[0x18]; + BinaryPrimitives.WriteUInt64LittleEndian(data, bufAddress); + BinaryPrimitives.WriteUInt64LittleEndian(data[0x08..], bufSize); + BinaryPrimitives.WriteUInt64LittleEndian(data[0x10..], offset); + Assert.True(_ctx.Memory.TryWrite(DataStructAddress, data)); + + Span request = stackalloc byte[0x10]; + request.Clear(); + BinaryPrimitives.WriteInt32LittleEndian(request, UserId); + BinaryPrimitives.WriteUInt64LittleEndian(request[0x08..], DataStructAddress); + Assert.True(_ctx.Memory.TryWrite(RequestAddress, request)); + } + + private CpuContext Invoke() + { + _ctx[CpuRegister.Rdi] = RequestAddress; + return _ctx; + } +}