diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 64404b3..05bb2e9 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -125,7 +125,6 @@ public static partial class KernelMemoryCompatExports // Mount components already found to exist without being reparse points. Titles resolve // every asset path at startup; re-reading each directory's attributes costs seconds. private static readonly ConcurrentDictionary _verifiedMountComponents = new(HostFsPath.Comparer); - private static readonly ConcurrentDictionary _verifiedMountDirectories = new(HostFsPath.Comparer); private static readonly ConcurrentDictionary _fullMountRoots = new(StringComparer.Ordinal); private static readonly ConcurrentDictionary _aprScannedDirectories = new(HostFsPath.Comparer); private static long _nextFileDescriptor = 2; @@ -235,6 +234,8 @@ public static partial class KernelMemoryCompatExports _guestMounts[normalizedMountPoint] = normalizedHostRoot; } + InvalidateResolvedGuestPaths(); + lock (_statCacheGate) { _negativeStatCache.RemoveWhere(path => @@ -252,10 +253,18 @@ public static partial class KernelMemoryCompatExports return false; } + bool removed; lock (_guestMountGate) { - return _guestMounts.Remove(normalizedMountPoint); + removed = _guestMounts.Remove(normalizedMountPoint); } + + if (removed) + { + InvalidateResolvedGuestPaths(); + } + + return removed; } internal static bool TryAllocateHleData( @@ -4725,6 +4734,38 @@ public static partial class KernelMemoryCompatExports return FileMode.Open; } + // Resolution is pure text work plus a per-component reparse-point check, but a + // resource streamer resolves the same paths hundreds of thousands of times through + // sceKernelAprResolveFilepathsToIdsAndFileSizes. Memoize the mapping and drop it + // whenever the mount table changes, which is the only input that can change an + // already-computed answer. + // Each entry records the mount-root configuration it was computed under, so a + // host configuration change cannot serve a stale answer. + private static readonly ConcurrentDictionary _resolvedGuestPaths = + new(StringComparer.Ordinal); + private const int ResolvedGuestPathCacheLimit = 1 << 20; + // ConcurrentDictionary.Count takes every bucket lock, which on a path resolved by + // a dozen guest threads at once costs far more than the resolution it guards. + private static int _resolvedGuestPathCount; + + internal static void InvalidateResolvedGuestPaths() + { + _resolvedGuestPaths.Clear(); + Interlocked.Exchange(ref _resolvedGuestPathCount, 0); + } + + // Every mount root a built-in branch can use. Resolution depends on nothing else + // that changes at runtime (the mount table clears the cache itself), so a memoized + // answer stays valid exactly as long as this token does. + private static string RootConfigurationToken() => + string.Concat( + Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_TEMP0_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_DOWNLOAD0_DIR"), "|", + Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR")); + public static string ResolveGuestPath(string guestPath) { if (string.IsNullOrWhiteSpace(guestPath)) @@ -4732,6 +4773,28 @@ public static partial class KernelMemoryCompatExports return guestPath; } + var roots = RootConfigurationToken(); + if (_resolvedGuestPaths.TryGetValue(guestPath, out var memoized) && + string.Equals(memoized.Root, roots, StringComparison.Ordinal)) + { + return memoized.Path; + } + + var resolved = ResolveGuestPathUncached(guestPath); + // Only a successful resolution is memoized: a denial is a containment + // decision about the host filesystem's current shape, so it stays live. + if (!string.IsNullOrEmpty(resolved) && + Volatile.Read(ref _resolvedGuestPathCount) < ResolvedGuestPathCacheLimit && + _resolvedGuestPaths.TryAdd(guestPath, (roots, resolved))) + { + Interlocked.Increment(ref _resolvedGuestPathCount); + } + + return resolved; + } + + private static string ResolveGuestPathUncached(string guestPath) + { if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath, out var mountPrefixMatched)) { return mountedPath; @@ -5098,38 +5161,16 @@ public static partial class KernelMemoryCompatExports Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { - var parent = current; current = Path.Combine(current, segment); + // Each component is checked once with its own lstat. Listing the parent instead + // stats every entry of that directory (.NET fills attributes per entry), so a game + // image with large directories paid for files it never opens: Demon's Souls spent + // ~9 s of its load in those stats. if (_verifiedMountComponents.ContainsKey(current)) { continue; } - // One listing returns every entry's attributes: verify the whole parent directory - // at once. Reparse points stay unverified and are rejected below. - if (_verifiedMountDirectories.TryAdd(parent, 0)) - { - try - { - foreach (var entry in new DirectoryInfo(parent).EnumerateFileSystemInfos()) - { - if ((entry.Attributes & FileAttributes.ReparsePoint) == 0) - { - _verifiedMountComponents.TryAdd(entry.FullName, 0); - } - } - } - catch (Exception ex) when ( - ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) - { - } - - if (_verifiedMountComponents.ContainsKey(current)) - { - continue; - } - } - try { if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) @@ -6819,6 +6860,19 @@ public static partial class KernelMemoryCompatExports return TryWriteHostPathStat(ctx, statAddress, hostPath, isDirectory); } + // /app0 is the read-only game image, so a path that is not in it now will not + // appear later. Remembering the absent ones turns a repeated pair of host stats + // on the (slow, external) game volume into a dictionary probe; Demon's Souls + // resolves tens of thousands of paths per boot and misses are the common case. + private static readonly ConcurrentDictionary _aprMissingImagePaths = new(HostFsPath.Comparer); + + private static bool IsUnderApp0(string cachePath) + { + var app0Root = ResolveApp0Root(); + return !string.IsNullOrWhiteSpace(app0Root) && + cachePath.StartsWith(Path.TrimEndingDirectorySeparator(app0Root) + Path.DirectorySeparatorChar, HostFsPath.Comparison); + } + private static bool TryGetAprFileSize(string hostPath, out ulong size) { size = 0; @@ -6838,6 +6892,11 @@ public static partial class KernelMemoryCompatExports return true; } + if (_aprMissingImagePaths.ContainsKey(cachePath)) + { + return false; + } + // One directory listing returns every file's size; titles resolve whole asset // directories at startup, so this replaces one host query per file. if (Path.GetDirectoryName(cachePath) is { } directory && _aprScannedDirectories.TryAdd(directory, 0)) @@ -6872,6 +6931,11 @@ public static partial class KernelMemoryCompatExports if (!new DirectoryInfo(cachePath).Exists) { + if (IsUnderApp0(cachePath)) + { + _aprMissingImagePaths.TryAdd(cachePath, 0); + } + return false; }