Merge pr/guest-path-resolution

This commit is contained in:
kuba 2026-09-30 00:57:24 +02:00
commit 642f25d88c
1 changed files with 92 additions and 28 deletions

View File

@ -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<string, byte> _verifiedMountComponents = new(HostFsPath.Comparer);
private static readonly ConcurrentDictionary<string, byte> _verifiedMountDirectories = new(HostFsPath.Comparer);
private static readonly ConcurrentDictionary<string, string> _fullMountRoots = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<string, byte> _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<string, (string Root, string Path)> _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<string, byte> _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;
}