diff --git a/source/Reloaded.Mod.Installer.Lib/Settings.cs b/source/Reloaded.Mod.Installer.Lib/Settings.cs
index 8c253c2b..52b6e6fe 100644
--- a/source/Reloaded.Mod.Installer.Lib/Settings.cs
+++ b/source/Reloaded.Mod.Installer.Lib/Settings.cs
@@ -35,8 +35,7 @@ public static Settings GetSettings(string[] args)
private static string GetSafeInstallPath()
{
var installPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
- bool hasNonAsciiChars = installPath.Any(c => c > 127);
- if (installPath.Contains("OneDrive") || hasNonAsciiChars)
+ if (IsPathInCloudSyncFolder(installPath))
{
var driveRoot = Path.GetPathRoot(Environment.SystemDirectory);
if (driveRoot == null)
@@ -46,4 +45,99 @@ private static string GetSafeInstallPath()
}
return installPath;
}
+
+ ///
+ /// Checks whether a given path is inside a cloud sync folder (OneDrive, Dropbox, Google Drive,
+ /// iCloud, Box, MEGA, etc.). Reloaded installed into such folders is avoided because many mods
+ /// do not tolerate cloud offload/locking, and load times are poor.
+ ///
+ /// Detection is layered, checked in order:
+ ///
+ /// - OneDrive environment variables (OneDrive / OneDriveCommercial).
+ ///
+ /// - The Windows Cloud Files API (CfGetSyncRootInfoByPath, available on Windows 10 1709+),
+ /// which detects any provider that registers a sync root.
+ ///
+ /// Desktop is only ever redirected by OneDrive, so these two tiers are sufficient for the install-path check.
+ ///
+ private static bool IsPathInCloudSyncFolder(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return false;
+
+ string fullPath;
+ try { fullPath = Path.GetFullPath(path); }
+ catch { return false; }
+
+ return IsInOneDrive(fullPath)
+ || IsInRegisteredCloudSyncRoot(fullPath);
+ }
+
+ // --- Tier 1: OneDrive environment variables ---
+ private static bool IsInOneDrive(string fullPath)
+ {
+ foreach (var envVar in s_oneDriveEnvVars)
+ {
+ var root = Environment.GetEnvironmentVariable(envVar);
+ if (string.IsNullOrEmpty(root))
+ continue;
+
+ try
+ {
+ var fullRoot = Path.GetFullPath(root)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ if (IsUnder(fullPath, fullRoot))
+ return true;
+ }
+ catch { /* malformed path/env - skip */ }
+ }
+
+ return false;
+ }
+
+ // --- Tier 2: Cloud Files API (cldapi.dll), Windows 10 1709+ ---
+ // A single native call reports whether the path is inside any registered cloud sync root,
+ // regardless of provider. No ancestor walk or hydration-state dependence.
+ private static bool IsInRegisteredCloudSyncRoot(string fullPath)
+ {
+ try
+ {
+ // CF_SYNC_ROOT_INFO_CLASS.CfSyncRootInfoBasic == 0.
+ // A small buffer is plenty for the basic info struct; we only care about the HRESULT.
+ var buffer = new byte[256];
+ int hr = CfGetSyncRootInfoByPath(fullPath, 0, buffer, buffer.Length, out _);
+ return hr >= 0; // S_OK (0) => the path resolves to a registered sync root.
+ }
+ catch
+ {
+ // cldapi.dll missing (older than Windows 10 1709) or call failed: rely on other tiers.
+ return false;
+ }
+ }
+
+ ///
+ /// True if equals or is a descendant of .
+ /// Case-insensitive. The separator is appended on the prefix check so a root named
+ /// e.g. "OneDrive" does not match a sibling like "OneDriveBackup".
+ ///
+ private static bool IsUnder(string fullPath, string root)
+ {
+ var trimmedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ if (trimmedRoot.Length == 0)
+ return false;
+
+ return fullPath.Equals(trimmedRoot, StringComparison.OrdinalIgnoreCase)
+ || fullPath.StartsWith(trimmedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [DllImport("cldapi.dll", CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.I4)]
+ private static extern int CfGetSyncRootInfoByPath(
+ string filePath,
+ int infoClass,
+ [Out] byte[] infoBuffer,
+ int infoBufferSize,
+ out int returnedLength);
+
+ private static readonly string[] s_oneDriveEnvVars = { "OneDrive", "OneDriveCommercial" };
}
\ No newline at end of file
diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs
index da7a1041..638d2acc 100644
--- a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs
+++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs
@@ -62,9 +62,9 @@ static string GetProductName(string exePath)
try { exePath = SymlinkResolver.GetFinalPathName(exePath); }
catch (Exception e) { Errors.HandleException(e, Resources.ErrorAddApplicationCantReadSymlink.Get()); }
- // Warn if OneDrive or NonAsciiChars detected in Game Path
+ // Warn if the Game Path is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars
bool hasNonAsciiChars = exePath.Any(c => c > 127);
- if (exePath.Contains("OneDrive") || hasNonAsciiChars)
+ if (PathUtility.IsPathInCloudSyncFolder(exePath) || hasNonAsciiChars)
{
var confirmAddAnyway = Actions.DisplayMessagebox.Invoke(Resources.ProblematicPathTitle.Get(), Resources.ProblematicPathAppDescription.Get(), new Actions.DisplayMessageBoxParams()
{
diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs
new file mode 100644
index 00000000..b31332ea
--- /dev/null
+++ b/source/Reloaded.Mod.Launcher.Lib/Utility/PathUtility.cs
@@ -0,0 +1,145 @@
+using System.Runtime.InteropServices;
+using Environment = System.Environment;
+
+namespace Reloaded.Mod.Launcher.Lib.Utility;
+
+///
+/// Utility methods for inspecting file system paths.
+///
+public static class PathUtility
+{
+ ///
+ /// Checks whether a given path is inside a cloud sync folder (OneDrive, Dropbox, Google Drive,
+ /// iCloud, Box, MEGA, etc.). Reloaded and games inside such folders are avoided because many mods
+ /// do not tolerate cloud offload/locking, and load times are poor.
+ ///
+ /// Detection is layered, checked in order:
+ ///
+ /// - OneDrive environment variables (OneDrive / OneDriveCommercial).
+ ///
+ /// - The Windows Cloud Files API (CfGetSyncRootInfoByPath, available on Windows 10 1709+),
+ /// which detects any provider that registers a sync root (OneDrive, Dropbox, iCloud, Box, ...).
+ ///
+ /// - Known cloud folder names under the user profile, as a fallback for older systems or
+ /// providers that do not register a sync root.
+ ///
+ /// The path to check.
+ /// True if the path is inside a cloud-synced folder; false otherwise.
+ public static bool IsPathInCloudSyncFolder(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return false;
+
+ string fullPath;
+ try { fullPath = Path.GetFullPath(path); }
+ catch { return false; }
+
+ return IsInOneDrive(fullPath)
+ || IsInRegisteredCloudSyncRoot(fullPath)
+ || IsInKnownCloudFolder(fullPath);
+ }
+
+ // --- Tier 1: OneDrive environment variables ---
+ private static bool IsInOneDrive(string fullPath)
+ {
+ foreach (var envVar in s_oneDriveEnvVars)
+ {
+ var root = Environment.GetEnvironmentVariable(envVar);
+ if (string.IsNullOrEmpty(root))
+ continue;
+
+ try
+ {
+ var fullRoot = Path.GetFullPath(root)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ if (IsUnder(fullPath, fullRoot))
+ return true;
+ }
+ catch { /* malformed path/env - skip */ }
+ }
+
+ return false;
+ }
+
+ // --- Tier 2: Cloud Files API (cldapi.dll), Windows 10 1709+ ---
+ // A single native call reports whether the path is inside any registered cloud sync root,
+ // regardless of provider. No ancestor walk or hydration-state dependence.
+ private static bool IsInRegisteredCloudSyncRoot(string fullPath)
+ {
+ try
+ {
+ // CF_SYNC_ROOT_INFO_CLASS.CfSyncRootInfoBasic == 0.
+ // A small buffer is plenty for the basic info struct; we only care about the HRESULT.
+ var buffer = new byte[256];
+ int hr = CfGetSyncRootInfoByPath(fullPath, 0, buffer, buffer.Length, out _);
+ return hr >= 0; // S_OK (0) => the path resolves to a registered sync root.
+ }
+ catch
+ {
+ // cldapi.dll missing (older than Windows 10 1709) or call failed: rely on other tiers.
+ return false;
+ }
+ }
+
+ // --- Tier 3: known cloud folder names under the user profile ---
+ private static bool IsInKnownCloudFolder(string fullPath)
+ {
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (string.IsNullOrEmpty(userProfile))
+ return false;
+
+ string fullProfile;
+ try { fullProfile = Path.GetFullPath(userProfile).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); }
+ catch { return false; }
+
+ foreach (var folder in s_knownCloudFolders)
+ {
+ try
+ {
+ if (IsUnder(fullPath, Path.Combine(fullProfile, folder)))
+ return true;
+ }
+ catch { /* malformed name - skip */ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// True if equals or is a descendant of .
+ /// Case-insensitive. The separator is appended on the prefix check so a root named
+ /// e.g. "OneDrive" does not match a sibling like "OneDriveBackup".
+ ///
+ private static bool IsUnder(string fullPath, string root)
+ {
+ var trimmedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ if (trimmedRoot.Length == 0)
+ return false;
+
+ return fullPath.Equals(trimmedRoot, StringComparison.OrdinalIgnoreCase)
+ || fullPath.StartsWith(trimmedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [DllImport("cldapi.dll", CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.I4)]
+ private static extern int CfGetSyncRootInfoByPath(
+ string filePath,
+ int infoClass,
+ [Out] byte[] infoBuffer,
+ int infoBufferSize,
+ out int returnedLength);
+
+ private static readonly string[] s_oneDriveEnvVars = { "OneDrive", "OneDriveCommercial" };
+
+ private static readonly string[] s_knownCloudFolders =
+ {
+ "Dropbox",
+ "Google Drive",
+ "GoogleDrive",
+ "iCloudDrive",
+ "Box Sync",
+ "Box",
+ "MEGA",
+ "pCloud"
+ };
+}
diff --git a/source/Reloaded.Mod.Launcher/App.xaml.cs b/source/Reloaded.Mod.Launcher/App.xaml.cs
index 3478489d..1df2724c 100644
--- a/source/Reloaded.Mod.Launcher/App.xaml.cs
+++ b/source/Reloaded.Mod.Launcher/App.xaml.cs
@@ -42,9 +42,9 @@ private void OnStartup(object sender, StartupEventArgs e)
// Need to construct MainWindow before invoking any dialog, otherwise Shutdown will be called on closing the dialog
var window = new MainWindow();
- // Warn if OneDrive or NonAsciiChars detected in Reloaded-II directory
+ // Warn if the Reloaded-II directory is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars
bool reloadedPathHasNonAsciiChars = AppContext.BaseDirectory.Any(c => c > 127);
- if (AppContext.BaseDirectory.Contains("OneDrive") || reloadedPathHasNonAsciiChars)
+ if (PathUtility.IsPathInCloudSyncFolder(AppContext.BaseDirectory) || reloadedPathHasNonAsciiChars)
{
Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathReloadedDescription.Get(), new Actions.DisplayMessageBoxParams()
{
@@ -54,12 +54,12 @@ private void OnStartup(object sender, StartupEventArgs e)
}
else // We only do this check if the Reloaded-II directory check passed
{
- // Warn if OneDrive or NonAsciiChars detected in Mods directory
+ // Warn if the Mods directory is inside a cloud sync folder (OneDrive, Dropbox, ...) or has NonAsciiChars
var modsDirectory = Lib.IoC.Get().GetModConfigDirectory();
if (modsDirectory != null)
{
bool modsDirectoryPathHasNonAsciiChars = modsDirectory.Any(c => c > 127);
- if (modsDirectory.Contains("OneDrive") || modsDirectoryPathHasNonAsciiChars)
+ if (PathUtility.IsPathInCloudSyncFolder(modsDirectory) || modsDirectoryPathHasNonAsciiChars)
{
Actions.DisplayMessagebox.Invoke(Lib.Static.Resources.ProblematicPathTitle.Get(), Lib.Static.Resources.ProblematicPathModsDescription.Get(), new Actions.DisplayMessageBoxParams()
{
diff --git a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs
index 57c53890..7084748f 100644
--- a/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs
+++ b/source/Reloaded.Mod.Loader.IO/Utility/Windows/WindowsDirectorySearcher.cs
@@ -174,17 +174,21 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List<
{
info = (FILE_DIRECTORY_INFORMATION*)currentBufferPtr;
- // Not symlink or symlink to offline file.
- if ((info->FileAttributes & FileAttributes.ReparsePoint) != 0 &&
- (info->FileAttributes & FileAttributes.Offline) == 0)
- goto nextfile;
-
var fileName = Marshal.PtrToStringUni(currentBufferPtr + sizeof(FILE_DIRECTORY_INFORMATION), (int)info->FileNameLength / 2);
if (fileName == "." || fileName == "..")
goto nextfile;
var isDirectory = (info->FileAttributes & FileAttributes.Directory) > 0;
+
+ // Skip symlinks/junctions that point elsewhere (prevents infinite recursion).
+ // Cloud folders (e.g. OneDrive) use different reparse tags, so they are
+ // treated as normal directories and enumerated. Only checked for directories;
+ // files with reparse points are always enumerated.
+ if (isDirectory && (info->FileAttributes & FileAttributes.ReparsePoint) != 0 &&
+ IsNameSurrogateReparsePoint($@"{originalDirPath}\{fileName}"))
+ goto nextfile;
+
if (isDirectory)
{
directories.Add(new DirectoryInformation
@@ -193,7 +197,7 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List<
LastWriteTime = info->LastWriteTime.ToDateTime()
});
}
- else if (!isDirectory)
+ else
{
files.Add(new FileInformation
{
@@ -218,6 +222,63 @@ public static unsafe bool TryGetDirectoryContents_Internal(string dirPath, List<
return true;
}
+ ///
+ /// Returns true if the reparse point at is a 'name surrogate'
+ /// (a symlink or junction/mount-point that redirects the path). Such entries are skipped
+ /// during enumeration to prevent following them into cycles.
+ ///
+ /// Cloud providers (e.g. OneDrive) also use reparse points on real, locally-available
+ /// folders; those tags do not carry the name-surrogate bit, so this returns false for them
+ /// and the directory is enumerated normally.
+ ///
+ /// On any failure to determine the tag we conservatively return false (do not skip), since
+ /// wrongly skipping a real directory is the original bug this guards against. A genuine
+ /// symlink whose tag cannot be read would still be bounded by maxDepth.
+ ///
+ private static unsafe bool IsNameSurrogateReparsePoint(string fullPath)
+ {
+ const uint FSCTL_GET_REPARSE_POINT = 0x000900A8;
+
+ // Bit set in the reparse tag of entries that substitute/redirect the name
+ // (symlinks, junctions/mount-points). Cloud reparse tags do not set this bit.
+ const uint REPARSE_TAG_NAME_SURROGATE = 0x20000000;
+
+ const uint GENERIC_READ = 0x80000000;
+ const uint FILE_SHARE_READ = 0x00000001;
+ const uint FILE_SHARE_WRITE = 0x00000002;
+ const uint OPEN_EXISTING = 3;
+
+ // BACKUP_SEMANTICS allows opening directories; OPEN_REPARSE_POINT opens the
+ // reparse entry itself instead of resolving through it.
+ const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
+ const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
+
+ const int BufferSize = 1024 * 16;
+
+ var handle = CreateFileW(fullPath, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
+ IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
+
+ if (handle == new IntPtr(-1))
+ return false;
+
+ try
+ {
+ byte* buffer = stackalloc byte[BufferSize];
+ if (!DeviceIoControl(handle, FSCTL_GET_REPARSE_POINT, IntPtr.Zero, 0,
+ (IntPtr)buffer, BufferSize, out _, IntPtr.Zero))
+ {
+ return false;
+ }
+
+ // REPARSE_DATA_BUFFER.ReparseTag is the first DWORD.
+ return (*(uint*)buffer & REPARSE_TAG_NAME_SURROGATE) != 0;
+ }
+ finally
+ {
+ CloseHandle(handle);
+ }
+ }
+
internal struct MultithreadedDirectorySearcher : IDisposable
{
private Thread[] _threads;
@@ -315,6 +376,17 @@ public void Dispose()
[DllImport("kernel32.dll", CharSet = CharSet.Ansi)]
static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ internal static extern IntPtr CreateFileW(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool CloseHandle(IntPtr hObject);
#endregion
#region Native Import Wrappers