Skip to content

Commit 1e9a4b9

Browse files
authored
Merge pull request #3997 from erri120/task/3978-1
Add `IGameLocationsService`
2 parents fa5abe6 + f505960 commit 1e9a4b9

File tree

20 files changed

+316
-163
lines changed

20 files changed

+316
-163
lines changed

src/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs

Lines changed: 67 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Concurrent;
2+
using System.Collections.Frozen;
23
using System.Diagnostics;
34
using System.Runtime.InteropServices;
45
using System.Text;
@@ -61,10 +62,10 @@ public class ALoadoutSynchronizer : ILoadoutSynchronizer
6162
private readonly ISynchronizerService _synchronizerService;
6263
private readonly IServiceProvider _serviceProvider;
6364
private readonly ILoadoutManager _loadoutManager;
65+
private readonly IGameLocationsService _gameLocationsService;
6466

6567
private readonly StringPool _fileNamePool = new();
6668

67-
6869
/// <summary>
6970
/// Connection.
7071
/// </summary>
@@ -90,6 +91,7 @@ protected ALoadoutSynchronizer(
9091
_synchronizerService = serviceProvider.GetRequiredService<ISynchronizerService>();
9192
_jobMonitor = serviceProvider.GetRequiredService<IJobMonitor>();
9293
_loadoutManager = serviceProvider.GetRequiredService<ILoadoutManager>();
94+
_gameLocationsService = serviceProvider.GetRequiredService<IGameLocationsService>();
9395

9496
_fileHashService = fileHashService;
9597

@@ -440,7 +442,7 @@ private static bool FileIsEnabled(LoadoutItem.ReadOnly arg)
440442
/// <inheritdoc />
441443
public async Task<Dictionary<GamePath, SyncNode>> BuildSyncTree(Loadout.ReadOnly loadout)
442444
{
443-
var metadata = await ReindexState(loadout.InstallationInstance, ignoreModifiedDates: false, Connection);
445+
var metadata = await ReindexState(loadout.InstallationInstance);
444446

445447
var currentItems = GetDiskStateForGame(metadata);
446448
var prevItems = ((ILoadoutSynchronizer)this).GetPreviouslyAppliedDiskState(metadata);
@@ -1161,11 +1163,11 @@ private bool ActionIngestFromDisk(Dictionary<GamePath, SyncNode> syncTree, Loado
11611163
return loadout;
11621164
}
11631165

1164-
public async Task<GameInstallMetadata.ReadOnly> RescanFiles(GameInstallation gameInstallation, bool ignoreModifiedDates)
1166+
public async Task<GameInstallMetadata.ReadOnly> RescanFiles(GameInstallation gameInstallation)
11651167
{
11661168
// Make sure the file hashes are up to date
11671169
await _fileHashService.GetFileHashesDb();
1168-
return await ReindexState(gameInstallation, ignoreModifiedDates, Connection);
1170+
return await ReindexState(gameInstallation);
11691171
}
11701172

11711173
/// <summary>
@@ -1464,196 +1466,110 @@ await Parallel.ForEachAsync(files, async (item, _) =>
14641466
await tx.Commit();
14651467
}
14661468

1467-
public Task<GameInstallMetadata.ReadOnly> ReindexState(GameInstallation installation, bool ignoreModifiedDates)
1468-
{
1469-
return ReindexState(installation, ignoreModifiedDates, Connection);
1470-
}
1471-
14721469
/// <summary>
14731470
/// Reindex the state of the game, running a transaction if changes are found
14741471
/// </summary>
1475-
private async Task<GameInstallMetadata.ReadOnly> ReindexState(GameInstallation installation, bool ignoreModifiedDates, IConnection connection)
1472+
public async Task<GameInstallMetadata.ReadOnly> ReindexState(GameInstallation installation)
14761473
{
14771474
using var _ = await _lock.LockAsync();
1478-
var originalMetadata = installation.GetMetadata(connection);
1479-
using var tx = connection.BeginTransaction();
1475+
var originalMetadata = installation.GetMetadata(Connection);
1476+
using var tx = Connection.BeginTransaction();
14801477

14811478
// Index the state
1482-
var changed = await ReindexState(installation, ignoreModifiedDates, connection, tx);
1483-
1479+
var changed = await ReindexState(installation, tx);
1480+
14841481
if (!originalMetadata.Contains(GameInstallMetadata.InitialDiskStateTransaction))
14851482
{
14861483
// No initial state, so set this transaction as the initial state
14871484
changed = true;
14881485
tx.Add(originalMetadata.Id, GameInstallMetadata.InitialDiskStateTransaction, EntityId.From(TxId.Tmp.Value));
14891486
}
1490-
1487+
14911488
if (changed)
14921489
{
14931490
tx.Add(installation.GameMetadataId, GameInstallMetadata.LastScannedDiskStateTransactionId, EntityId.From(TxId.Tmp.Value));
14941491
await tx.Commit();
14951492
}
1496-
1497-
return GameInstallMetadata.Load(connection.Db, installation.GameMetadataId);
1498-
}
1499-
1500-
/// <summary>
1501-
/// Reindex the state of the game
1502-
/// </summary>
1503-
public async Task<bool> ReindexState(GameInstallation installation, bool ignoreModifiedDates, IConnection connection, ITransaction tx)
1504-
{
1505-
var hashDb = await _fileHashService.GetFileHashesDb();
15061493

1507-
var gameInstallMetadata = GameInstallMetadata.Load(connection.Db, installation.GameMetadataId);
1494+
return GameInstallMetadata.Load(Connection.Db, installation.GameMetadataId);
1495+
}
15081496

1509-
var previousDiskStateEntities = gameInstallMetadata.DiskStateEntries;
1510-
var previousDiskState = new Dictionary<GamePath, DiskStateEntry.ReadOnly>(capacity: previousDiskStateEntities.Count);
1497+
private FrozenDictionary<GamePath, DiskStateEntry.ReadOnly> GetDiskState(GameInstallMetadata.ReadOnly gameInstallMetadata)
1498+
{
1499+
var entities = gameInstallMetadata.DiskStateEntries;
1500+
var result = new Dictionary<GamePath, DiskStateEntry.ReadOnly>(capacity: entities.Count);
15111501

1512-
foreach (var previousDiskStateEntity in previousDiskStateEntities)
1502+
foreach (var entity in entities)
15131503
{
1514-
GamePath path = previousDiskStateEntity.Path;
1515-
1516-
ref var diskStateEntity = ref CollectionsMarshal.GetValueRefOrAddDefault(previousDiskState, path, out var hasExistingDiskStateEntity);
1517-
if (hasExistingDiskStateEntity)
1504+
GamePath gamePath = entity.Path;
1505+
ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(result, gamePath, out var isDuplicate);
1506+
if (isDuplicate)
15181507
{
1519-
Logger.LogWarning("Duplicate path in disk state: `{Path}`", path);
1508+
Logger.LogWarning("Duplicate path in disk state: `{Path}`", gamePath);
15201509
}
15211510

1522-
diskStateEntity = previousDiskStateEntity;
1511+
entry = entity;
15231512
}
15241513

1525-
var hasDiskStateChanged = false;
1514+
return result.ToFrozenDictionary();
1515+
}
15261516

1527-
var seenPaths = new HashSet<GamePath>();
1528-
var seenPathsLock = new Lock();
1517+
/// <summary>
1518+
/// Reindex the state of the game
1519+
/// </summary>
1520+
public async Task<bool> ReindexState(GameInstallation installation, ITransaction tx)
1521+
{
1522+
var gameInstallMetadata = GameInstallMetadata.Load(Connection.Db, installation.GameMetadataId);
1523+
var previousState = GetDiskState(gameInstallMetadata);
1524+
1525+
var indexGameResult = await _gameLocationsService.IndexGame(
1526+
installation: installation,
1527+
previousDiskState: previousState,
1528+
filter: GamePathFilter,
1529+
cancellationToken: CancellationToken.None
1530+
);
15291531

1530-
foreach (var locationPair in installation.LocationsRegister.GetTopLevelLocations())
1532+
foreach (var (gamePath, result) in indexGameResult.NewFiles)
15311533
{
1532-
var (_, locationPath) = locationPair;
1533-
if (!locationPath.DirectoryExists()) continue;
1534-
1535-
await Parallel.ForEachAsync(locationPath.EnumerateFiles(), async (file, token) =>
1534+
_ = new DiskStateEntry.New(tx, tx.TempId(DiskStateEntry.EntryPartition))
15361535
{
1537-
try
1538-
{
1539-
var gamePath = installation.LocationsRegister.ToGamePath(file);
1540-
if (ShouldIgnorePathWhenIndexing(gamePath)) return;
1541-
1542-
bool isNewPath;
1543-
lock (seenPathsLock)
1544-
{
1545-
isNewPath = seenPaths.Add(gamePath);
1546-
}
1547-
1548-
if (!isNewPath)
1549-
{
1550-
Logger.LogDebug("Skipping already indexed file at `{Path}`", file);
1551-
return;
1552-
}
1553-
1554-
if (previousDiskState.TryGetValue(gamePath, out var previousDiskStateEntry))
1555-
{
1556-
var fileInfo = file.FileInfo;
1557-
var writeTimeUtc = new DateTimeOffset(fileInfo.LastWriteTimeUtc);
1558-
1559-
// If the files don't match, update the entry
1560-
if (writeTimeUtc != previousDiskStateEntry.LastModified || fileInfo.Size != previousDiskStateEntry.Size || ignoreModifiedDates)
1561-
{
1562-
var newHash = await MaybeHashFile(hashDb, gamePath, file,
1563-
fileInfo, token
1564-
);
1565-
tx.Add(previousDiskStateEntry.Id, DiskStateEntry.Size, fileInfo.Size);
1566-
tx.Add(previousDiskStateEntry.Id, DiskStateEntry.Hash, newHash);
1567-
tx.Add(previousDiskStateEntry.Id, DiskStateEntry.LastModified, writeTimeUtc);
1568-
hasDiskStateChanged = true;
1569-
}
1570-
}
1571-
else
1572-
{
1573-
var newHash = await MaybeHashFile(hashDb, gamePath, file,
1574-
file.FileInfo, token
1575-
);
1576-
1577-
_ = new DiskStateEntry.New(tx, tx.TempId(DiskStateEntry.EntryPartition))
1578-
{
1579-
Path = gamePath.ToGamePathParentTuple(gameInstallMetadata.Id),
1580-
Hash = newHash,
1581-
Size = file.FileInfo.Size,
1582-
LastModified = file.FileInfo.LastWriteTimeUtc,
1583-
GameId = gameInstallMetadata.Id,
1584-
};
1585-
1586-
hasDiskStateChanged = true;
1587-
}
1588-
}
1589-
catch (Exception ex)
1590-
{
1591-
throw ex;
1592-
}
1593-
});
1536+
Path = gamePath.ToGamePathParentTuple(gameInstallMetadata.Id),
1537+
Hash = result.Hash,
1538+
Size = result.Size,
1539+
LastModified = result.LastModified,
1540+
GameId = gameInstallMetadata.Id,
1541+
};
15941542
}
15951543

1596-
// NOTE(erri120): remove files from the disk state that don't exist on disk anymore
1597-
foreach (var entry in previousDiskState.Values)
1544+
foreach (var (gamePath, result) in indexGameResult.ModifiedFiles)
15981545
{
1599-
if (seenPaths.Contains(entry.Path)) continue;
1600-
tx.Delete(entry.Id, recursive: false);
1601-
hasDiskStateChanged = true;
1602-
}
1546+
var didFind = previousState.TryGetValue(gamePath, out var previousDiskStateEntry);
1547+
Debug.Assert(didFind, "modified file should be in previous state");
16031548

1604-
if (hasDiskStateChanged) tx.Add(gameInstallMetadata.Id, GameInstallMetadata.LastScannedDiskStateTransaction, EntityId.From(TxId.Tmp.Value));
1605-
return hasDiskStateChanged;
1606-
}
1607-
1608-
private async ValueTask<Hash> MaybeHashFile(IDb hashDb, GamePath gamePath, AbsolutePath file, IFileEntry fileInfo, CancellationToken token)
1609-
{
1610-
Hash? diskMinimalHash = null;
1549+
tx.Add(previousDiskStateEntry.Id, DiskStateEntry.Size, result.Size);
1550+
tx.Add(previousDiskStateEntry.Id, DiskStateEntry.Hash, result.Hash);
1551+
tx.Add(previousDiskStateEntry.Id, DiskStateEntry.LastModified, result.LastModified);
1552+
}
16111553

1612-
var foundHash = Hash.Zero;
1613-
var needFullHash = true;
1614-
1615-
// Look for all known files that match the path
1616-
foreach (var matchingPath in PathHashRelation.FindByPath(hashDb, gamePath.Path))
1554+
foreach (var gamePath in indexGameResult.RemovedFiles)
16171555
{
1618-
// Make sure the size matches
1619-
var hash = matchingPath.Hash;
1620-
if (hash.Size.Value != fileInfo.Size)
1621-
continue;
1622-
1623-
// If the minimal hash matches, then we can use the xxHash3 hash
1624-
await using (var fileStream = file.Read())
1625-
{
1626-
diskMinimalHash ??= await MultiHasher.MinimalHash(fileStream, cancellationToken: token);
1627-
}
1628-
1629-
if (hash.MinimalHash == diskMinimalHash)
1630-
{
1631-
// We previously found a hash that matches the minimal hash, make sure the xxHash3 matches, otherwise we
1632-
// have a hash collision
1633-
if (foundHash != Hash.Zero && foundHash != hash.XxHash3)
1634-
{
1635-
// We have a hash collision, so we need to do a full hash
1636-
needFullHash = true;
1637-
break;
1638-
}
1556+
var didFind = previousState.TryGetValue(gamePath, out var previousDiskStateEntry);
1557+
Debug.Assert(didFind, "modified file should be in previous state");
16391558

1640-
// Store the hash
1641-
foundHash = hash.XxHash3;
1642-
needFullHash = false;
1643-
}
1559+
tx.Delete(previousDiskStateEntry.Id, recursive: false);
16441560
}
1645-
1646-
if (!needFullHash)
1647-
return foundHash;
16481561

1649-
Logger.LogDebug("Didn't find matching hash data for file `{Path}` or found multiple matches, falling back to doing a full hash", file);
1650-
return await file.XxHash3Async(token: token);
1562+
var hasChanged = indexGameResult.NewFiles.Count != 0 || indexGameResult.ModifiedFiles.Count != 0 || indexGameResult.RemovedFiles.Count != 0;
1563+
if (!hasChanged) return false;
1564+
1565+
tx.Add(gameInstallMetadata.Id, GameInstallMetadata.LastScannedDiskStateTransaction, EntityId.From(TxId.Tmp.Value));
1566+
return true;
16511567
}
16521568

16531569
public async Task ActivateLoadout(LoadoutId loadoutId)
16541570
{
16551571
var loadout = Loadout.Load(Connection.Db, loadoutId);
1656-
var reindexed = await ReindexState(loadout.InstallationInstance, ignoreModifiedDates: false, Connection);
1572+
var reindexed = await ReindexState(loadout.InstallationInstance);
16571573

16581574
var tree = BuildSyncTree(DiskStateToPathPartPair(reindexed.DiskStateEntries), DiskStateToPathPartPair(reindexed.DiskStateEntries), loadout);
16591575
ProcessSyncTree(tree);
@@ -1694,7 +1610,7 @@ private LoadoutGameFilesGroup.New CreateLoadoutGameFilesGroup(LoadoutId loadout,
16941610
/// Files ignored by this method will not be included in the sync tree. Prefer not including
16951611
/// the path in the first place instead of using this method.
16961612
/// </remarks>
1697-
protected virtual bool ShouldIgnorePathWhenIndexing(GamePath path) => false;
1613+
protected virtual IGamePathFilter GamePathFilter { get; } = Synchronizers.GamePathFilters.Empty;
16981614

16991615
/// <summary>
17001616
/// Gets a set of files intrinsic to this game. Such as mod order files, preference files, etc.
@@ -1709,7 +1625,7 @@ public virtual Dictionary<GamePath, IIntrinsicFile> IntrinsicFiles(Loadout.ReadO
17091625
public async Task ResetToOriginalGameState(GameInstallation installation, LocatorId[] locatorIds)
17101626
{
17111627
var gameState = _fileHashService.GetGameFiles((installation.Store, locatorIds));
1712-
var metaData = await ReindexState(installation, ignoreModifiedDates: false, Connection);
1628+
var metaData = await ReindexState(installation);
17131629

17141630
List<PathPartPair> diskState = [];
17151631

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Collections.Frozen;
2+
using JetBrains.Annotations;
3+
using NexusMods.Abstractions.GameLocators;
4+
using NexusMods.Hashing.xxHash3;
5+
using NexusMods.Paths;
6+
7+
namespace NexusMods.Abstractions.Loadouts.Synchronizers;
8+
9+
[PublicAPI]
10+
public record struct IndexFileResult(
11+
GamePath Path,
12+
Hash Hash,
13+
Size Size,
14+
DateTimeOffset LastModified
15+
);
16+
17+
[PublicAPI]
18+
public record IndexGameResult(
19+
FrozenDictionary<GamePath, IndexFileResult> NewFiles,
20+
FrozenDictionary<GamePath, IndexFileResult> ModifiedFiles,
21+
FrozenSet<GamePath> RemovedFiles
22+
);
23+
24+
/// <summary>
25+
/// Service responsible for handling operations on game locations/installations.
26+
/// </summary>
27+
[PublicAPI]
28+
public interface IGameLocationsService
29+
{
30+
Task<IndexGameResult> IndexGame(
31+
GameInstallation installation,
32+
FrozenDictionary<GamePath, DiskStateEntry.ReadOnly> previousDiskState,
33+
IGamePathFilter filter,
34+
CancellationToken cancellationToken = default
35+
);
36+
37+
// Task RemoveEmptyDirectories(GameInstallation installation, CancellationToken cancellationToken = default);
38+
}
39+
40+
[PublicAPI]
41+
public interface IGamePathFilter
42+
{
43+
bool ShouldFilter(GamePath gamePath);
44+
}
45+
46+
public static class GamePathFilters
47+
{
48+
public static readonly IGamePathFilter Empty = new EmptyFilter();
49+
50+
public static IGamePathFilter Create(Func<GamePath, bool> predicate)
51+
{
52+
return new PredicateFilter(predicate);
53+
}
54+
55+
private class PredicateFilter : IGamePathFilter
56+
{
57+
private readonly Func<GamePath, bool> _predicate;
58+
59+
public PredicateFilter(Func<GamePath, bool> predicate)
60+
{
61+
_predicate = predicate;
62+
}
63+
64+
public bool ShouldFilter(GamePath gamePath) => _predicate(gamePath);
65+
}
66+
67+
private class EmptyFilter : IGamePathFilter
68+
{
69+
public bool ShouldFilter(GamePath gamePath) => false;
70+
}
71+
}

0 commit comments

Comments
 (0)