Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Downloads/DownloadQueueController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ private async Task DownloadAndInstall(DownloadQueueItem item, DownloadRequest re

// Extract if needed (we treat extract as 0..100 in its own bar)
// Don't extract archives when install flat is enabled
if (req.HasMultipleFiles || (req.AutoExtract && IsFileCompressed(req.GamePath) && !req.InstallFlat))
if (req.DownloadAsArchive || (req.AutoExtract && IsFileCompressed(req.GamePath) && !req.InstallFlat))
{
item.SetStatus(DownloadStatus.Extracting, "Extracting...");
Logger.Info($"Extracting {req.GamePath}...");
Expand Down
2 changes: 1 addition & 1 deletion Downloads/DownloadRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class DownloadRequest
public string DownloadUrl { get; set; }
public string InstallDir { get; set; } // folder where to extract/install
public string GamePath { get; set; } // full path to the downloaded file on disk
public bool HasMultipleFiles { get; set; } // whether archive contains multiple top-level files
public bool DownloadAsArchive { get; set; } // download is the ROM's folder as one archive, so it always gets extracted
public bool AutoExtract { get; set; } = true;
public bool Use7z { get; set; } = false;
public string PathTo7Z { get; set; } = "";
Expand Down
24 changes: 15 additions & 9 deletions Games/RomMImport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,13 @@ private Game ImportGame(RomMRom ROM, Guid StatusID)
// not the display Name. Using Name drops the extension and can include characters that
// don't match the installed file, breaking IsInstalled detection and the play path.
//
// For folder-based ROMs we point at a real file inside the ROM's folder (fs_name):
// - nested single file: the one file in the folder,
// - multiple files: the primary file (the download descriptor's FileName is the folder
// name / archive base, which is not itself a real file, so use the primary file here).
// The factory already resolved which file that is, so the install path and the download
// descriptor cannot drift apart: for a folder download it is the primary file inside the
// ROM's folder (FileName there is the folder / archive base, not a real file), and for a
// single-file download it is the downloaded file itself.
var baseRevision = BuildRevision(ROM);
var folderName = baseRevision?.FolderName;
var playableFile = ROM.HasMultipleFiles
? RomMRevisionFactory.RelativeFilePath(RomMRevisionFactory.SelectPrimaryFile(ROM.Files), folderName)
: baseRevision?.FileName;
var playableFile = baseRevision?.PlayableFile;
// With no file list, fs_name still beats the extensionless display Name.
var fileName = !string.IsNullOrEmpty(playableFile) ? playableFile
: !string.IsNullOrEmpty(ROM.FileName) ? ROM.FileName
Expand All @@ -210,8 +208,16 @@ private Game ImportGame(RomMRom ROM, Guid StatusID)
return null;
}

var gameInstallDir = _mapping.InstallFlat ? rootInstallDir : RomMInstallPaths.InstallDir(rootInstallDir, folderName, fileName);
var pathToGame = _mapping.InstallFlat ? $"{rootInstallDir}\\{fileName}" : RomMInstallPaths.GamePath(rootInstallDir, folderName, fileName);
// A ROM fetched as a whole folder keeps a folder of its own even under install flat, so
// the extras it carries are not scattered across the platform folder.
var flatLayout = RomMInstallPaths.UsesFlatLayout(
_mapping.InstallFlat,
RomMInstallPaths.IsSingleRomInFolder(
baseRevision?.DownloadAsArchive ?? false,
baseRevision?.HasMultipleFiles ?? false));

var gameInstallDir = flatLayout ? rootInstallDir : RomMInstallPaths.InstallDir(rootInstallDir, folderName, fileName);
var pathToGame = flatLayout ? $"{rootInstallDir}\\{fileName}" : RomMInstallPaths.GamePath(rootInstallDir, folderName, fileName);

var status = _plugin.Playnite.Database.CompletionStatuses.Get(StatusID);
var completionStatusProperty = status != null ? new MetadataNameProperty(status.Name) : null;
Expand Down
39 changes: 33 additions & 6 deletions Games/RomMInstallController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,20 @@ public override void Install(InstallActionArgs args)
// IsInstalled detection lines up.
var installDir = RomMInstallPaths.InstallDir(dstPath, _gameData.FolderName, _gameData.FileName);

if (_gameData.Mapping.InstallFlat)
var singleRomInFolder = RomMInstallPaths.IsSingleRomInFolder(
_gameData.DownloadAsArchive, _gameData.HasMultipleFiles);

// Not _gameData.Mapping.InstallFlat directly: a ROM fetched as a whole folder keeps its
// own folder even under flat. Must stay the same call the importer makes, or the install
// path drifts from the one recorded at import and IsInstalled detection breaks.
var flatLayout = RomMInstallPaths.UsesFlatLayout(_gameData.Mapping.InstallFlat, singleRomInFolder);

if (flatLayout)
installDir = dstPath;

// If RomM indicates multiple files, we download as an archive name (zip) into the install folder.
// Otherwise we download the single ROM file.
var downloadFilePath = _gameData.HasMultipleFiles
// A folder download arrives as an archive named after the ROM folder; a single file keeps
// its own name. See RomMRevision.DownloadAsArchive for why this is not HasMultipleFiles.
var downloadFilePath = _gameData.DownloadAsArchive
? Path.Combine(installDir, _gameData.FileName + ".zip")
: Path.Combine(installDir, _gameData.FileName);

Expand All @@ -68,9 +76,11 @@ public override void Install(InstallActionArgs args)
Use7z = _romM.Settings.Use7z,
PathTo7Z = _romM.Settings.PathTo7z,

HasMultipleFiles = _gameData.HasMultipleFiles,
DownloadAsArchive = _gameData.DownloadAsArchive,
AutoExtract = _gameData.Mapping != null && _gameData.Mapping.AutoExtract,
InstallFlat = _gameData.Mapping.InstallFlat,
// The layout actually being installed, so cancelling a download cleans up the game's
// folder when it has one.
InstallFlat = flatLayout,

// Called by queue AFTER download/extract is done
BuildRoms = () =>
Expand All @@ -84,6 +94,23 @@ public override void Install(InstallActionArgs args)
return roms;
}

// A single ROM in a folder has one launchable file; its extras are not offered
// as alternative ROMs the way a multi-file ROM's discs are. A missing or unsafe
// file falls through to the folder scan rather than leaving no ROM at all.
if (singleRomInFolder &&
!string.IsNullOrEmpty(_gameData.PlayableFile) &&
RomMInstallPaths.IsContained(_gameData.PlayableFile))
{
var primaryPath = Path.Combine(installDir, _gameData.PlayableFile);
if (File.Exists(primaryPath))
{
roms.Add(new GameRom(Game.Name, primaryPath));
return roms;
}

Logger.Warn($"Expected {primaryPath} after extracting {Game.Name}; falling back to scanning the install folder.");
}

// Otherwise, we assume extracted files are in installDir
var supported = GetEmulatorSupportedFileTypes(_gameData);
var actualRomFiles = GetRomFiles(installDir, supported);
Expand Down
84 changes: 82 additions & 2 deletions Games/RomMInstallPaths.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

Expand Down Expand Up @@ -39,10 +40,10 @@ public static string ResolveWithin(string root, string relativePath)
if (string.IsNullOrEmpty(relativePath))
throw new ArgumentException("Archive entry has no name, refusing to extract it.");

var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var fullRoot = Path.GetFullPath(root);
var destination = Path.GetFullPath(Path.Combine(fullRoot, Contained(relativePath)));

if (!destination.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
if (!IsInside(fullRoot, destination))
throw new ArgumentException($"Path escapes the install directory: {relativePath}");

return destination;
Expand All @@ -65,5 +66,84 @@ public static string GamePath(string rootInstallDir, string fileName)
// <install dir>/<file name>, using the folder-aware install dir.
public static string GamePath(string rootInstallDir, string folderName, string fileName)
=> Path.Combine(InstallDir(rootInstallDir, folderName, fileName), Contained(fileName));

// Whether an install lays a ROM's files straight into the mapping's folder, the way "install
// flat" asks, or gives the game a folder of its own.
//
// A ROM fetched as a whole folder brings its own subfolders (patch/, dlc/) with it, so it
// cannot go flat: extraction would scatter them across the platform folder, where the patch/
// of every game would merge into a single one, and a flat uninstall -- which removes only the
// files registered as the game's ROMs -- would leave them behind. Those ROMs keep a folder of
// their own even when the mapping asks for flat; a single file still installs flat, which is
// what the setting is for.
//
// ROMs RomM itself calls multi-file stay on the flat path they already take: moving them
// would strand installations whose paths were recorded the old way.
//
// The importer and the install controller both ask here, so the path one computes cannot
// drift from the other's -- they have to agree or IsInstalled detection stops matching.
public static bool UsesFlatLayout(bool installFlat, bool singleRomInFolder)
=> installFlat && !singleRomInFolder;

// A folder fetched whole for a ROM RomM still calls single-file (see
// RomMRevision.DownloadAsArchive): one playable file, plus extras that are not ROMs.
public static bool IsSingleRomInFolder(bool downloadAsArchive, bool hasMultipleFiles)
=> downloadAsArchive && !hasMultipleFiles;

// Whether path sits strictly inside root, ignoring a trailing separator and case.
//
// Uninstall removes a directory only when it can show the directory belongs to one game, and
// this is that proof. Two cases fail it, both of which must keep their files rather than lose
// a folder: a game installed flat, whose install directory *is* the mapping's folder, shared
// with every other game on the platform; and an install directory that no longer sits under
// the mapping at all, because the destination was repointed after the game was installed --
// there the stale path is the previous platform folder, and deleting it would take every ROM
// in it. Asking how the game was actually installed also survives the setting being toggled
// afterwards, which the mapping's own flag does not.
public static bool IsInside(string root, string path)
{
if (string.IsNullOrEmpty(root) || string.IsNullOrEmpty(path))
return false;

return StartsWithDirectory(NormalizeDirectory(path), NormalizeDirectory(root));
}

// Whether some other game's install directory is installDir itself or lies inside it -- the
// folder every flat-installed game on a platform shares, or one that a simple single file and
// a same-named folder ROM both derive. IsInside alone cannot tell such a folder from a game's
// own: a mapping repointed to the parent of its old flat folder ("D:\Roms\SNES" -> "D:\Roms")
// puts that old platform folder inside the new destination, and deleting it would take every
// ROM still in it.
public static bool IsClaimedByAnother(string installDir, IEnumerable<string> otherInstallDirs)
{
if (string.IsNullOrEmpty(installDir) || otherInstallDirs == null)
return false;

var normalized = NormalizeDirectory(installDir);
return otherInstallDirs
.Where(other => !string.IsNullOrEmpty(other))
.Select(NormalizeDirectory)
.Any(other => string.Equals(other, normalized, StringComparison.OrdinalIgnoreCase)
|| StartsWithDirectory(other, normalized));
}

// Both arguments already normalised.
private static bool StartsWithDirectory(string path, string root)
=> path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);

private static string NormalizeDirectory(string path)
{
try
{
path = Path.GetFullPath(path);
}
catch (Exception)
{
// A path the filesystem will not resolve is compared as it arrived rather than
// throwing: this only decides which uninstall branch runs.
}

return path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}
}
28 changes: 25 additions & 3 deletions Games/RomMRevisionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,44 @@ public static string RelativeFilePath(RomMFile file, string folderName)
: file.FileName;
}

// Whether the ROM's whole folder has to be fetched instead of a single file.
//
// RomM reports a ROM whose folder holds extra content -- a Switch game with its update and
// DLC in patch/ and dlc/ subfolders -- as a *nested single file*, because the game is the
// only file at the folder's root. Trusting that flag alone downloads the game and silently
// leaves the rest on the server, so a folder holding more than one file is fetched whole,
// the way a ROM RomM itself calls multi-file already is.
//
// has_simple_single_file is excluded on purpose: there fs_name names the file itself rather
// than a folder, so there is no folder to ask the server for.
public static bool DownloadsWholeFolder(RomMRom rom)
=> rom.HasMultipleFiles
|| (rom.HasNestedSingleFile && (rom.Files?.Count ?? 0) > 1);

// Returns null when a single-file ROM has no resolvable file. Single files use the 4.9
// /files/content endpoint when a file id is present, falling back to the rom-level endpoint
// (so we never emit "api/roms//files/content/..."); multi-file ROMs download the whole archive.
// (so we never emit "api/roms//files/content/..."); folder downloads take the whole archive.
public static RomMRevision Build(RomMRom rom, string romMHost)
{
var revision = new RomMRevision
{
Id = rom.Id,
// What RomM says the ROM is, kept as-is: it decides whether every extracted file is
// a playable ROM (the discs of a multi-file game) or only the primary one.
HasMultipleFiles = rom.HasMultipleFiles,
DownloadAsArchive = DownloadsWholeFolder(rom),
IsSelected = false
};

if (!rom.HasMultipleFiles)
if (!revision.DownloadAsArchive)
{
var romfile = SelectPrimaryFile(rom.Files);
if (romfile == null)
return null;

revision.FileName = romfile.FileName;
// The downloaded file is itself the one to launch.
revision.PlayableFile = romfile.FileName;
// A nested single file lives inside a folder named after the ROM (fs_name); a simple
// single file sits directly in the platform folder and has no wrapping folder.
revision.FolderName = rom.HasNestedSingleFile ? rom.FileName : null;
Expand All @@ -69,8 +88,11 @@ public static RomMRevision Build(RomMRom rom, string romMHost)
else
{
revision.FileName = rom.FileName;
// Multi-file ROMs are always stored in a folder named after the ROM (fs_name).
// Folder-based ROMs are always stored in a folder named after the ROM (fs_name).
revision.FolderName = rom.FileName;
// FileName is the folder / archive base, which is not itself a real file, so the file
// to launch is resolved here once and reused by the importer for the install path.
revision.PlayableFile = RelativeFilePath(SelectPrimaryFile(rom.Files), rom.FileName);
revision.DownloadURL = RomMUrl.Combine(romMHost, $"api/roms/{rom.Id}/content/{rom.FileName}");
}

Expand Down
Loading
Loading