Scan and Logging Performance Improvements

- Parallelized Steam ACF and DLC scanning
- Replaced lock-based logging with async channel to avoid UI locks during parallel thread calls.
- Added MD5 cache during file checks to reduce redundant I/O calls
This commit is contained in:
Frog
2026-06-21 00:30:04 -07:00
parent c4aaab8bdc
commit 0478088442
4 changed files with 134 additions and 78 deletions
+1 -6
View File
@@ -657,12 +657,7 @@ internal sealed partial class SelectForm : CustomForm
} }
Stopwatch gameDlcTimer = Stopwatch.StartNew(); Stopwatch gameDlcTimer = Stopwatch.StartNew();
foreach (Task task in appTasks) await Task.WhenAll(appTasks);
{
if (Program.Canceled)
return;
await task;
}
gameDlcTimer.Stop(); gameDlcTimer.Stop();
gameQueriesDone.TrySetResult(); gameQueriesDone.TrySetResult();
+44 -37
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@@ -29,52 +30,57 @@ internal static class SteamLibrary
internal static async Task<List<(string appId, string name, string branch, int buildId, string gameDirectory)>> internal static async Task<List<(string appId, string name, string branch, int buildId, string gameDirectory)>>
GetGames() GetGames()
=> await Task.Run(async () => {
Stopwatch timer = Stopwatch.StartNew();
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
HashSet<string> seenAppIds = new();
HashSet<string> gameLibraryDirectories = await GetLibraryDirectories();
ProgramData.Log($"[Steam] Found {gameLibraryDirectories.Count} library folder(s).");
foreach (string libraryDirectory in gameLibraryDirectories)
{ {
Stopwatch timer = Stopwatch.StartNew(); if (Program.Canceled)
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new(); return games;
HashSet<string> gameLibraryDirectories = await GetLibraryDirectories(); ProgramData.Log($"[Steam] Scanning library: {libraryDirectory}");
ProgramData.Log($"[Steam] Found {gameLibraryDirectories.Count} library folder(s)."); foreach ((string appId, string name, string branch, int buildId, string gameDirectory) game in
foreach (string libraryDirectory in gameLibraryDirectories) await GetGamesFromLibraryDirectory(libraryDirectory))
{ {
if (Program.Canceled) if (seenAppIds.Add(game.appId))
return games;
ProgramData.Log($"[Steam] Scanning library: {libraryDirectory}");
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) game in (await
GetGamesFromLibraryDirectory(
libraryDirectory)).Where(game => games.All(_game => _game.appId != game.appId)))
games.Add(game); games.Add(game);
} }
}
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) testGame in foreach ((string appId, string name, string branch, int buildId, string gameDirectory) testGame in
TestGames.Where(t => games.All(g => g.appId != t.appId))) TestGames.Where(t => !seenAppIds.Contains(t.appId)))
games.Add(testGame); games.Add(testGame);
if (TestGames.Count > 0) if (TestGames.Count > 0)
ProgramData.Log($"[Steam] Injected {TestGames.Count} test game(s)."); ProgramData.Log($"[Steam] Injected {TestGames.Count} test game(s).");
timer.Stop(); timer.Stop();
ProgramData.Log($"[Steam] Total games detected: {games.Count} in {(timer.Elapsed.TotalSeconds >= 60 ? $"{timer.Elapsed.TotalSeconds / 60:F1} minutes" : $"{timer.Elapsed.TotalSeconds:F1}s")}"); ProgramData.Log($"[Steam] Total games detected: {games.Count} in {(timer.Elapsed.TotalSeconds >= 60 ? $"{timer.Elapsed.TotalSeconds / 60:F1} minutes" : $"{timer.Elapsed.TotalSeconds:F1}s")}");
return games; return games;
}); }
private static async Task<List<(string appId, string name, string branch, int buildId, string gameDirectory)>> private static async Task<List<(string appId, string name, string branch, int buildId, string gameDirectory)>>
GetGamesFromLibraryDirectory(string libraryDirectory) GetGamesFromLibraryDirectory(string libraryDirectory)
=> await Task.Run(() => => await Task.Run(() =>
{ {
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
if (Program.Canceled || !libraryDirectory.DirectoryExists()) if (Program.Canceled || !libraryDirectory.DirectoryExists())
{ {
ProgramData.Log($"[Steam] Skipping library (not found or canceled): {libraryDirectory}"); ProgramData.Log($"[Steam] Skipping library (not found or canceled): {libraryDirectory}");
return games; return [];
} }
foreach (string file in libraryDirectory.EnumerateDirectory("*.acf")) ConcurrentDictionary<string, (string name, string branch, int buildId, string gameDirectory)> gamesDict = new();
Parallel.ForEach(libraryDirectory.EnumerateDirectory("*.acf"), (file, state) =>
{ {
if (Program.Canceled) if (Program.Canceled)
return games; {
state.Stop();
return;
}
if (!ValveDataFile.TryDeserialize(file.ReadFile(), out VProperty result)) if (!ValveDataFile.TryDeserialize(file.ReadFile(), out VProperty result))
{ {
ProgramData.Log($"[Steam] Failed to deserialize ACF: {file}"); ProgramData.Log($"[Steam] Failed to deserialize ACF: {file}");
continue; return;
} }
string appId = result.Value.GetChild("appid")?.ToString(); string appId = result.Value.GetChild("appid")?.ToString();
@@ -82,11 +88,10 @@ internal static class SteamLibrary
string name = result.Value.GetChild("name")?.ToString(); string name = result.Value.GetChild("name")?.ToString();
string buildId = result.Value.GetChild("buildid")?.ToString(); string buildId = result.Value.GetChild("buildid")?.ToString();
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(installdir) || if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(installdir) ||
string.IsNullOrWhiteSpace(name) string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(buildId))
|| string.IsNullOrWhiteSpace(buildId))
{ {
ProgramData.Log($"[Steam] Skipping ACF with missing fields: {file}"); ProgramData.Log($"[Steam] Skipping ACF with missing fields: {file}");
continue; return;
} }
string rawGameDirectory = libraryDirectory + @"\common\" + installdir; string rawGameDirectory = libraryDirectory + @"\common\" + installdir;
@@ -94,12 +99,11 @@ internal static class SteamLibrary
if (gameDirectory is null) if (gameDirectory is null)
{ {
ProgramData.Log($"[Steam] Game directory not found (drive may be slow or disconnected): {rawGameDirectory} | App: {name} ({appId})"); ProgramData.Log($"[Steam] Game directory not found (drive may be slow or disconnected): {rawGameDirectory} | App: {name} ({appId})");
continue; return;
} }
if (!int.TryParse(appId, out int _) || !int.TryParse(buildId, out int buildIdInt) || if (!int.TryParse(appId, out int _) || !int.TryParse(buildId, out int buildIdInt))
games.Any(g => g.appId == appId)) return;
continue;
VToken userConfig = result.Value.GetChild("UserConfig"); VToken userConfig = result.Value.GetChild("UserConfig");
string branch = userConfig?.GetChild("BetaKey")?.ToString(); string branch = userConfig?.GetChild("BetaKey")?.ToString();
@@ -114,10 +118,13 @@ internal static class SteamLibrary
if (string.IsNullOrWhiteSpace(branch)) if (string.IsNullOrWhiteSpace(branch))
branch = "public"; branch = "public";
ProgramData.Log($"[Steam] Detected game: {name} ({appId}) | Branch: {branch} | Dir: {gameDirectory}"); if (gamesDict.TryAdd(appId, (name, branch, buildIdInt, gameDirectory)))
games.Add((appId, name, branch, buildIdInt, gameDirectory)); ProgramData.Log($"[Steam] Detected game: {name} ({appId}) | Branch: {branch} | Dir: {gameDirectory}");
} });
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new(gamesDict.Count);
foreach (KeyValuePair<string, (string name, string branch, int buildId, string gameDirectory)> kv in gamesDict)
games.Add((kv.Key, kv.Value.name, kv.Value.branch, kv.Value.buildId, kv.Value.gameDirectory));
return games; return games;
}); });
+10 -14
View File
@@ -111,19 +111,7 @@ internal static class Resources
!rootDirectory.IsCommonIncorrectExecutable(path)) !rootDirectory.IsCommonIncorrectExecutable(path))
&& (validFunc is null || validFunc(path)) && && (validFunc is null || validFunc(path)) &&
path.TryGetFileBinaryType(out BinaryType binaryType) && path.TryGetFileBinaryType(out BinaryType binaryType) &&
binaryType is BinaryType.BIT64) binaryType is BinaryType.BIT64 or BinaryType.BIT32)
executables.Add((path, binaryType));
}
foreach (string path in rootDirectory.EnumerateDirectory("*.exe", true))
{
if (Program.Canceled)
return null;
if (executables.All(e => e.path != path) && (!filterCommon ||
!rootDirectory.IsCommonIncorrectExecutable(path))
&& (validFunc is null || validFunc(path)) &&
path.TryGetFileBinaryType(out BinaryType binaryType) &&
binaryType is BinaryType.BIT32)
executables.Add((path, binaryType)); executables.Add((path, binaryType));
} }
@@ -202,11 +190,19 @@ internal static class Resources
return dllDirectories.Count > 0 ? dllDirectories : null; return dllDirectories.Count > 0 ? dllDirectories : null;
}); });
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> Md5HashCache = new();
#pragma warning disable CA5351 #pragma warning disable CA5351
private static string ComputeMD5(this string filePath) private static string ComputeMD5(this string filePath)
=> filePath.FileExists() && filePath.ReadFileBytes(true) is { } bytes {
if (Md5HashCache.TryGetValue(filePath, out string cached))
return cached;
string hash = filePath.FileExists() && filePath.ReadFileBytes(true) is { } bytes
? BitConverter.ToString(MD5.HashData(bytes)).Replace("-", "").ToUpperInvariant() ? BitConverter.ToString(MD5.HashData(bytes)).Replace("-", "").ToUpperInvariant()
: null; : null;
Md5HashCache[filePath] = hash;
return hash;
}
#pragma warning restore CA5351 #pragma warning restore CA5351
internal static bool IsResourceFile(this string filePath, ResourceIdentifier identifier) internal static bool IsResourceFile(this string filePath, ResourceIdentifier identifier)
+79 -21
View File
@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using CreamInstaller; using CreamInstaller;
@@ -81,16 +82,69 @@ internal static event Action<string> OnLogSteam;
internal static event Action<string> OnLogWarning; internal static event Action<string> OnLogWarning;
internal static event Action<string> OnLogError; internal static event Action<string> OnLogError;
private static readonly object LogLock = new(); private static string FormatLogEntry(string message)
{
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
return $"[{timestamp}] {message}{Environment.NewLine}";
}
private static string FormatLogErrorEntry(string message, Exception ex)
{
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
return $"[{timestamp}] [ERROR] {message}{Environment.NewLine}[{timestamp}] [ERROR] Exception: {ex}{Environment.NewLine}";
}
private readonly record struct LogEntry(string Path, string Text);
private static readonly Channel<LogEntry> LogChannel = Channel.CreateUnbounded<LogEntry>();
private static readonly Task LogConsumer;
static ProgramData()
{
LogConsumer = ConsumeLogAsync();
}
private static async Task ConsumeLogAsync()
{
ChannelReader<LogEntry> reader = LogChannel.Reader;
List<LogEntry> batch = new(64);
while (await reader.WaitToReadAsync())
{
batch.Clear();
while (reader.TryRead(out LogEntry entry))
{
batch.Add(entry);
if (batch.Count >= 64)
break;
}
if (batch.Count > 0)
FlushLogBatch(batch);
}
}
private static void FlushLogBatch(List<LogEntry> entries)
{
try
{
foreach (IGrouping<string, LogEntry> group in entries.GroupBy(e => e.Path))
{
StringBuilder combined = new(group.Sum(e => e.Text.Length));
foreach (LogEntry entry in group)
_ = combined.Append(entry.Text);
File.AppendAllText(group.Key, combined.ToString(), Encoding.UTF8);
}
}
catch
{
// ignored; logging must never crash the application
}
}
internal static void Log(string message) internal static void Log(string message)
{ {
try try
{ {
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); LogChannel.Writer.TryWrite(new LogEntry(ScanLogPath, FormatLogEntry(message)));
string entry = $"[{timestamp}] {message}{Environment.NewLine}";
lock (LogLock)
File.AppendAllText(ScanLogPath, entry, Encoding.UTF8);
} }
catch catch
{ {
@@ -103,10 +157,7 @@ internal static event Action<string> OnLogError;
{ {
try try
{ {
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); LogChannel.Writer.TryWrite(new LogEntry(SteamLogPath, FormatLogEntry(message)));
string entry = $"[{timestamp}] {message}{Environment.NewLine}";
lock (LogLock)
File.AppendAllText(SteamLogPath, entry, Encoding.UTF8);
} }
catch catch
{ {
@@ -119,10 +170,7 @@ internal static event Action<string> OnLogError;
{ {
try try
{ {
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); LogChannel.Writer.TryWrite(new LogEntry(AppLogPath, FormatLogEntry($"[WARN] {message}")));
string entry = $"[{timestamp}] [WARN] {message}{Environment.NewLine}";
lock (LogLock)
File.AppendAllText(AppLogPath, entry, Encoding.UTF8);
} }
catch catch
{ {
@@ -135,12 +183,10 @@ internal static event Action<string> OnLogError;
{ {
try try
{ {
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = ex is not null string entry = ex is not null
? $"[{timestamp}] [ERROR] {message}{Environment.NewLine}[{timestamp}] [ERROR] Exception: {ex}{Environment.NewLine}" ? FormatLogErrorEntry(message, ex)
: $"[{timestamp}] [ERROR] {message}{Environment.NewLine}"; : FormatLogEntry($"[ERROR] {message}");
lock (LogLock) LogChannel.Writer.TryWrite(new LogEntry(AppLogPath, entry));
File.AppendAllText(AppLogPath, entry, Encoding.UTF8);
} }
catch catch
{ {
@@ -188,13 +234,25 @@ internal static event Action<string> OnLogError;
OldProgramChoicesPath.DeleteFile(); OldProgramChoicesPath.DeleteFile();
}); });
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> CooldownCache = new();
internal static bool CheckCooldown(string identifier, int cooldown) internal static bool CheckCooldown(string identifier, int cooldown)
{ {
DateTime now = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
DateTime lastCheck = GetCooldown(identifier) ?? now;
bool cooldownOver = (now - lastCheck).TotalSeconds > cooldown; if (CooldownCache.TryGetValue(identifier, out DateTime cached) &&
if (cooldownOver || now == lastCheck) (now - cached).TotalSeconds <= cooldown)
return false;
DateTime? lastCheck = GetCooldown(identifier);
DateTime effective = lastCheck ?? now;
bool cooldownOver = (now - effective).TotalSeconds > cooldown;
CooldownCache[identifier] = now;
if (cooldownOver || now == effective)
SetCooldown(identifier, now); SetCooldown(identifier, now);
return cooldownOver; return cooldownOver;
} }