From 5fcc8eaa37ffd6f627d9bb89d8c2ccfa66e87c35 Mon Sep 17 00:00:00 2001 From: ParantezTech Date: Mon, 29 Jun 2026 13:30:08 +0300 Subject: [PATCH] [saveData] add dir search --- src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs | 2 + src/SharpEmu.Libs/SaveData/SaveDataExports.cs | 444 ++++++++++++++++++ .../VideoOut/VulkanVideoPresenter.cs | 11 +- 3 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 src/SharpEmu.Libs/SaveData/SaveDataExports.cs diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs index 72374db..1b7601a 100644 --- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs +++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs @@ -10,6 +10,7 @@ using SharpEmu.HLE; using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.Kernel; using SharpEmu.Libs.AppContent; +using SharpEmu.Libs.SaveData; using System.Buffers.Binary; using System.Collections.Generic; using System.Linq; @@ -136,6 +137,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime KernelModuleRegistry.Reset(); var image = LoadImage(normalizedEbootPath); VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version); + SaveDataExports.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.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs new file mode 100644 index 0000000..4af371b --- /dev/null +++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs @@ -0,0 +1,444 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using System.Buffers.Binary; +using System.Text; + +namespace SharpEmu.Libs.SaveData; + +public static class SaveDataExports +{ + private const int OrbisSaveDataErrorParameter = unchecked((int)0x809F0000); + private const int OrbisSaveDataErrorInternal = unchecked((int)0x809F000B); + private const int SaveDataTitleIdSize = 10; + private const int SaveDataDirNameSize = 32; + private const int SaveDataParamSize = 0x530; + private const int SaveDataSearchInfoSize = 0x30; + private const ulong ResultHitNumOffset = 0x00; + private const ulong ResultDirNamesOffset = 0x08; + private const ulong ResultDirNamesNumOffset = 0x10; + private const ulong ResultSetNumOffset = 0x14; + private const ulong ResultParamsOffset = 0x18; + private const ulong ResultInfosOffset = 0x20; + private const uint SortKeyFreeBlocks = 5; + private const uint SortOrderDescent = 1; + private static readonly object _stateGate = new(); + private static string? _titleId; + + public static void ConfigureApplicationInfo(string? titleId) + { + lock (_stateGate) + { + _titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim()); + } + } + + [SysAbiExport( + Nid = "TywrFKCoLGY", + ExportName = "sceSaveDataInitialize3", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataInitialize3(CpuContext ctx) + { + try + { + Directory.CreateDirectory(ResolveSaveDataRoot()); + return SetReturn(ctx, 0); + } + catch (IOException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + catch (UnauthorizedAccessException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + } + + [SysAbiExport( + Nid = "dyIhnXq-0SM", + ExportName = "sceSaveDataDirNameSearch", + Target = Generation.Gen4 | Generation.Gen5, + LibraryName = "libSceSaveData")] + public static int SaveDataDirNameSearch(CpuContext ctx) + { + var condAddress = ctx[CpuRegister.Rdi]; + var resultAddress = ctx[CpuRegister.Rsi]; + if (condAddress == 0 || resultAddress == 0) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + if (!TryReadSearchCond(ctx, condAddress, out var cond) || + !TryReadSearchResult(ctx, resultAddress, out var result)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (cond.UserId < 0 || cond.SortKey > SortKeyFreeBlocks || cond.SortOrder > SortOrderDescent) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + try + { + string titleId; + if (cond.TitleIdAddress == 0) + { + titleId = ResolveConfiguredTitleId(); + } + else if (!TryReadFixedAscii(ctx, cond.TitleIdAddress, SaveDataTitleIdSize, out titleId)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + var root = ResolveTitleSaveRoot(cond.UserId, titleId); + var entries = Directory.Exists(root) + ? EnumerateSaveDirectories(root, cond.Pattern) + : []; + + entries = SortEntries(entries, cond.SortKey, cond.SortOrder); + var setNum = result.DirNamesNum == 0 + ? 0 + : Math.Min(result.DirNamesNum, entries.Count); + if (!TryWriteUInt32(ctx, resultAddress + ResultHitNumOffset, checked((uint)entries.Count)) || + !TryWriteUInt32(ctx, resultAddress + ResultSetNumOffset, checked((uint)setNum))) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (setNum == 0) + { + TraceSaveData($"dir_name_search user={cond.UserId} title={titleId} hits={entries.Count} set=0 root='{root}'"); + return SetReturn(ctx, 0); + } + + if (result.DirNamesAddress == 0) + { + return SetReturn(ctx, OrbisSaveDataErrorParameter); + } + + for (var i = 0; i < setNum; i++) + { + var entry = entries[i]; + if (!TryWriteFixedAscii( + ctx, + result.DirNamesAddress + ((ulong)i * SaveDataDirNameSize), + SaveDataDirNameSize, + entry.Name) || + (result.ParamsAddress != 0 && + !TryWriteParam(ctx, result.ParamsAddress + ((ulong)i * SaveDataParamSize), entry)) || + (result.InfosAddress != 0 && + !TryWriteSearchInfo(ctx, result.InfosAddress + ((ulong)i * SaveDataSearchInfoSize), entry))) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + } + + TraceSaveData($"dir_name_search user={cond.UserId} title={titleId} hits={entries.Count} set={setNum} root='{root}'"); + return SetReturn(ctx, 0); + } + catch (IOException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + catch (UnauthorizedAccessException) + { + return SetReturn(ctx, OrbisSaveDataErrorInternal); + } + } + + private static bool TryReadSearchCond(CpuContext ctx, ulong address, out SearchCond cond) + { + cond = default; + if (!TryReadInt32(ctx, address, out var userId) || + !ctx.TryReadUInt64(address + 0x08, out var titleIdAddress) || + !ctx.TryReadUInt64(address + 0x10, out var dirNameAddress) || + !TryReadUInt32(ctx, address + 0x18, out var sortKey) || + !TryReadUInt32(ctx, address + 0x1C, out var sortOrder)) + { + return false; + } + + string pattern; + if (dirNameAddress == 0) + { + pattern = string.Empty; + } + else if (!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out pattern)) + { + return false; + } + + cond = new SearchCond(userId, titleIdAddress, pattern, sortKey, sortOrder); + return true; + } + + private static bool TryReadSearchResult(CpuContext ctx, ulong address, out SearchResult result) + { + result = default; + if (!ctx.TryReadUInt64(address + ResultDirNamesOffset, out var dirNamesAddress) || + !TryReadUInt32(ctx, address + ResultDirNamesNumOffset, out var dirNamesNum) || + !ctx.TryReadUInt64(address + ResultParamsOffset, out var paramsAddress) || + !ctx.TryReadUInt64(address + ResultInfosOffset, out var infosAddress)) + { + return false; + } + + result = new SearchResult(dirNamesAddress, dirNamesNum, paramsAddress, infosAddress); + return true; + } + + private static List EnumerateSaveDirectories(string root, string pattern) + { + var entries = new List(); + foreach (var directory in Directory.EnumerateDirectories(root)) + { + var name = Path.GetFileName(directory); + if (string.IsNullOrWhiteSpace(name) || + name.StartsWith("sce_", StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrEmpty(pattern) && !MatchPattern(name, pattern))) + { + continue; + } + + var info = new DirectoryInfo(directory); + entries.Add(new SaveEntry(name, directory, info.LastWriteTimeUtc)); + } + + return entries; + } + + private static List SortEntries(List entries, uint sortKey, uint sortOrder) + { + IOrderedEnumerable sorted = sortKey switch + { + 3 => entries.OrderBy(entry => entry.LastWriteUtc), + _ => entries.OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase), + }; + + var list = sorted.ToList(); + if (sortOrder == SortOrderDescent) + { + list.Reverse(); + } + + return list; + } + + private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry) + { + var param = new byte[SaveDataParamSize]; + WriteAscii(param.AsSpan(0x00, 128), "Saved Data"); + WriteAscii(param.AsSpan(0x100, 1024), entry.Name); + BinaryPrimitives.WriteInt64LittleEndian( + param.AsSpan(0x508, sizeof(long)), + new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds()); + return ctx.Memory.TryWrite(address, param); + } + + private static bool TryWriteSearchInfo(CpuContext ctx, ulong address, SaveEntry entry) + { + var size = GetDirectorySize(entry.Path); + var usedBlocks = checked((ulong)((size + 32767) / 32768)); + var blocks = Math.Max(96UL, usedBlocks); + Span info = stackalloc byte[SaveDataSearchInfoSize]; + info.Clear(); + BinaryPrimitives.WriteUInt64LittleEndian(info[0x00..], blocks); + BinaryPrimitives.WriteUInt64LittleEndian(info[0x08..], blocks - usedBlocks); + return ctx.Memory.TryWrite(address, info); + } + + private static long GetDirectorySize(string root) + { + long total = 0; + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + total += new FileInfo(file).Length; + } + + return total; + } + + private static bool MatchPattern(string value, string pattern) => + MatchPattern(value.AsSpan(), pattern.AsSpan()); + + private static bool MatchPattern(ReadOnlySpan value, ReadOnlySpan pattern) + { + if (pattern.IsEmpty) + { + return value.IsEmpty; + } + + if (pattern[0] == '%') + { + for (var i = 0; i <= value.Length; i++) + { + if (MatchPattern(value[i..], pattern[1..])) + { + return true; + } + } + + return false; + } + + if (value.IsEmpty) + { + return false; + } + + if (pattern[0] == '_' || + char.ToUpperInvariant(pattern[0]) == char.ToUpperInvariant(value[0])) + { + return MatchPattern(value[1..], pattern[1..]); + } + + return false; + } + + private static string ResolveTitleSaveRoot(int userId, string titleId) => + Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId)); + + private static string ResolveSaveDataRoot() + { + var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR"); + var root = string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Environment.CurrentDirectory, "user", "savedata") + : configured; + return Path.GetFullPath(root); + } + + private static string ResolveConfiguredTitleId() + { + lock (_stateGate) + { + if (!string.IsNullOrWhiteSpace(_titleId)) + { + return _titleId; + } + } + + var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"); + var app0Name = string.IsNullOrWhiteSpace(app0Root) + ? null + : Path.GetFileName(Path.TrimEndingDirectorySeparator(app0Root)); + if (!string.IsNullOrWhiteSpace(app0Name)) + { + var candidate = app0Name.Split('-', StringSplitOptions.RemoveEmptyEntries)[0]; + if (!string.IsNullOrWhiteSpace(candidate)) + { + return SanitizePathSegment(candidate); + } + } + + return "default"; + } + + private static string SanitizePathSegment(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized; + } + + private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value) + { + value = string.Empty; + Span buffer = stackalloc byte[length]; + if (!ctx.Memory.TryRead(address, buffer)) + { + return false; + } + + var stringLength = buffer.IndexOf((byte)0); + if (stringLength < 0) + { + stringLength = buffer.Length; + } + + value = Encoding.ASCII.GetString(buffer[..stringLength]); + return true; + } + + private static bool TryWriteFixedAscii(CpuContext ctx, ulong address, int length, string value) + { + Span buffer = stackalloc byte[length]; + buffer.Clear(); + WriteAscii(buffer, value); + return ctx.Memory.TryWrite(address, buffer); + } + + private static void WriteAscii(Span destination, string value) + { + var count = Math.Min(value.Length, Math.Max(0, destination.Length - 1)); + for (var i = 0; i < count; i++) + { + var ch = value[i]; + destination[i] = ch <= 0x7F ? (byte)ch : (byte)'?'; + } + } + + private static bool TryReadInt32(CpuContext ctx, ulong address, out int value) + { + Span bytes = stackalloc byte[sizeof(int)]; + if (!ctx.Memory.TryRead(address, bytes)) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadInt32LittleEndian(bytes); + return true; + } + + private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value) + { + Span bytes = stackalloc byte[sizeof(uint)]; + if (!ctx.Memory.TryRead(address, bytes)) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadUInt32LittleEndian(bytes); + return true; + } + + private static bool TryWriteUInt32(CpuContext ctx, ulong address, uint value) + { + Span bytes = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, value); + return ctx.Memory.TryWrite(address, bytes); + } + + private static int SetReturn(CpuContext ctx, int result) + { + ctx[CpuRegister.Rax] = unchecked((ulong)result); + return result; + } + + private static void TraceSaveData(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_SAVEDATA"), "1", StringComparison.Ordinal)) + { + Console.Error.WriteLine($"[LOADER][TRACE] savedata.{message}"); + } + } + + private readonly record struct SearchCond( + int UserId, + ulong TitleIdAddress, + string Pattern, + uint SortKey, + uint SortOrder); + + private readonly record struct SearchResult( + ulong DirNamesAddress, + uint DirNamesNum, + ulong ParamsAddress, + ulong InfosAddress); + + private readonly record struct SaveEntry(string Name, string Path, DateTime LastWriteUtc); +} diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index e2e8aae..36b0660 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -26,6 +26,7 @@ internal static unsafe class VulkanVideoPresenter private static uint _windowWidth; private static uint _windowHeight; private static bool _closed; + private static bool _splashHidden; public static void EnsureStarted(uint width, uint height) { @@ -55,7 +56,15 @@ internal static unsafe class VulkanVideoPresenter _windowWidth = width; _windowHeight = height; - _latestPresentation ??= hasSplash + _latestPresentation ??= _splashHidden + ? new Presentation( + CreateBlackFrame(width, height), + width, + height, + 1, + GuestDrawKind.None, + IsSplash: false) + : hasSplash ? new Presentation( splashPixels, splashWidth,