using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using System.Xml.XPath;
using StardewModdingAPI.Toolkit.Utilities;
using System.Reflection;
#if SMAPI_FOR_WINDOWS
using Microsoft.Win32;
using VdfParser;
#endif
namespace StardewModdingAPI.Toolkit.Framework.GameScanning
{
/// Finds installed game folders.
[SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid game install paths.")]
public class GameScanner
{
/*********
** Fields
*********/
/// The current OS.
private readonly Platform Platform;
/// The Steam app ID for Stardew Valley.
private const string SteamAppId = "413150";
/*********
** Public methods
*********/
/// Construct an instance.
public GameScanner()
{
this.Platform = EnvironmentUtility.DetectPlatform();
}
/// Find all valid Stardew Valley install folders.
/// This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS.
public IEnumerable Scan()
{
// get install paths
IEnumerable paths = this
.GetCustomInstallPaths()
.Concat(this.GetDefaultInstallPaths())
.Select(path => PathUtilities.NormalizePath(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
// yield valid folders
foreach (string path in paths)
{
DirectoryInfo folder = new(path);
if (this.LooksLikeGameFolder(folder))
yield return folder;
}
}
/// Get whether a folder seems to contain the game.
/// The folder to check.
public bool LooksLikeGameFolder(DirectoryInfo dir)
{
return this.GetGameFolderType(dir) == GameFolderType.Valid;
}
/// Detect the validity of a game folder based on file structure heuristics.
/// The folder to check.
public GameFolderType GetGameFolderType(DirectoryInfo dir)
{
// no such folder
if (!dir.Exists)
return GameFolderType.NoGameFound;
// apparently valid
if (File.Exists(Path.Combine(dir.FullName, "Stardew Valley.dll")))
return GameFolderType.Valid;
// doesn't contain any version of Stardew Valley
FileInfo executable = new(Path.Combine(dir.FullName, "Stardew Valley.exe"));
if (!executable.Exists)
executable = new(Path.Combine(dir.FullName, "StardewValley.exe")); // pre-1.5.5 Linux/macOS executable
if (!executable.Exists)
return GameFolderType.NoGameFound;
// get assembly version
Version? version;
try
{
version = AssemblyName.GetAssemblyName(executable.FullName).Version;
if (version == null)
return GameFolderType.InvalidUnknown;
}
catch
{
// The executable exists but it doesn't seem to be a valid assembly. This would
// happen with Stardew Valley 1.5.5+, but that should have been flagged as a valid
// folder before this point.
return GameFolderType.InvalidUnknown;
}
// ignore Stardew Valley 1.5.5+ at this point
if (version.Major == 1 && version.Minor == 3 && version.Build == 37)
return GameFolderType.InvalidUnknown;
// incompatible version
if (version.Major == 1 && version.Minor < 4)
{
// Stardew Valley 1.5.4 and earlier have assembly versions <= 1.3.7853.31734
if (version.Minor < 3 || version.Build <= 7853)
return GameFolderType.Legacy154OrEarlier;
// Stardew Valley 1.5.5+ legacy compatibility branch
return GameFolderType.LegacyCompatibilityBranch;
}
return GameFolderType.InvalidUnknown;
}
/*********
** Private methods
*********/
/// The default file paths where Stardew Valley can be installed.
/// Derived from the crossplatform mod config.
private IEnumerable GetDefaultInstallPaths()
{
switch (this.Platform)
{
case Platform.Linux:
case Platform.Mac:
{
string home = Environment.GetEnvironmentVariable("HOME")!;
// Linux
yield return $"{home}/GOG Games/Stardew Valley/game";
yield return Directory.Exists($"{home}/.steam/steam/steamapps/common/Stardew Valley")
? $"{home}/.steam/steam/steamapps/common/Stardew Valley"
: $"{home}/.local/share/Steam/steamapps/common/Stardew Valley";
// macOS
yield return "/Applications/Stardew Valley.app/Contents/MacOS";
yield return $"{home}/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS";
}
break;
case Platform.Windows:
{
// Windows registry
#if SMAPI_FOR_WINDOWS
IDictionary registryKeys = new Dictionary
{
[@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App " + GameScanner.SteamAppId] = "InstallLocation", // Steam
[@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows
};
foreach (var pair in registryKeys)
{
string? path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value);
if (!string.IsNullOrWhiteSpace(path))
yield return path;
}
// via Steam library path
string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath");
if (steamPath != null)
{
// conventional path
yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley");
// from Steam's .vdf file
string? path = this.GetPathFromSteamLibrary(steamPath);
if (!string.IsNullOrWhiteSpace(path))
yield return path;
}
#endif
// default GOG/Steam paths
foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" })
{
yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley";
yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley";
yield return $@"{programFiles}\GOG Games\Stardew Valley";
yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley";
}
// default Xbox app paths
// The Xbox app saves the install path to the registry, but we can't use it
// here since it saves the internal readonly path (like C:\Program Files\WindowsApps\Mutable\)
// instead of the mods-enabled path(like C:\Program Files\ModifiableWindowsApps\Stardew Valley).
// Fortunately we can cheat a bit: players can customize the install drive, but they can't
// change the install path on the drive.
for (char driveLetter = 'C'; driveLetter <= 'H'; driveLetter++)
yield return $@"{driveLetter}:\Program Files\ModifiableWindowsApps\Stardew Valley";
}
break;
default:
throw new InvalidOperationException($"Unknown platform '{this.Platform}'.");
}
}
/// Get the custom install path from the stardewvalley.targets file in the home directory, if any.
private IEnumerable GetCustomInstallPaths()
{
// get home path
string homePath = Environment.GetEnvironmentVariable(this.Platform == Platform.Windows ? "USERPROFILE" : "HOME")!;
if (string.IsNullOrWhiteSpace(homePath))
yield break;
// get targets file
FileInfo file = new(Path.Combine(homePath, "stardewvalley.targets"));
if (!file.Exists)
yield break;
// parse file
XElement root;
try
{
using FileStream stream = file.OpenRead();
root = XElement.Load(stream);
}
catch
{
yield break;
}
// get install path
XElement? element = root.XPathSelectElement("//*[local-name() = 'GamePath']"); // can't use '//GamePath' due to the default namespace
if (!string.IsNullOrWhiteSpace(element?.Value))
yield return element.Value.Trim();
}
#if SMAPI_FOR_WINDOWS
/// Get the value of a key in the Windows HKLM registry.
/// The full path of the registry key relative to HKLM.
/// The name of the value.
private string? GetLocalMachineRegistryValue(string key, string name)
{
RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine;
RegistryKey? openKey = localMachine.OpenSubKey(key);
if (openKey == null)
return null;
using (openKey)
return (string?)openKey.GetValue(name);
}
/// Get the value of a key in the Windows HKCU registry.
/// The full path of the registry key relative to HKCU.
/// The name of the value.
private string? GetCurrentUserRegistryValue(string key, string name)
{
RegistryKey currentUser = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) : Registry.CurrentUser;
RegistryKey? openKey = currentUser.OpenSubKey(key);
if (openKey == null)
return null;
using (openKey)
return (string?)openKey.GetValue(name);
}
/// Get the game directory path from alternative Steam library locations.
/// The full path to the directory containing steam.exe.
/// The game directory, if found.
private string? GetPathFromSteamLibrary(string? steamPath)
{
try
{
if (steamPath == null)
return null;
// get .vdf file path
string libraryFoldersPath = Path.Combine(steamPath.Replace('/', '\\'), "steamapps\\libraryfolders.vdf");
if (!File.Exists(libraryFoldersPath))
return null;
// read data
using FileStream fileStream = File.OpenRead(libraryFoldersPath);
VdfDeserializer deserializer = new();
dynamic libraries = deserializer.Deserialize(fileStream);
if (libraries?.libraryfolders is null)
return null;
// get path from Stardew Valley app (if any)
foreach (dynamic pair in libraries.libraryfolders)
{
dynamic library = pair.Value;
foreach (dynamic app in library.apps)
{
string key = app.Key;
if (key == GameScanner.SteamAppId)
{
string path = library.path;
return Path.Combine(path.Replace("\\\\", "\\"), "steamapps", "common", "Stardew Valley");
}
}
}
return null;
}
catch
{
// The file might not be parseable in some cases (e.g. some players have an older Steam version using
// a different format). Ideally we'd log an error to know when it's actually an issue, but the SMAPI
// installer doesn't have a logging mechanism (and third-party code calling the toolkit may not either).
// So for now, just ignore the error and fallback to the other discovery mechanisms.
return null;
}
}
#endif
}
}