Merge pull request #37 from FroggMaster/dlc-background-refresh-fixes

DLC Background Refresh Fixes, API Optimization, and Game/DLC Data Query Improvements
This commit is contained in:
Frog
2026-07-24 02:00:35 -07:00
committed by GitHub
7 changed files with 328 additions and 154 deletions
@@ -255,6 +255,23 @@ internal sealed class CustomTreeView : TreeView
TextRenderer.DrawText(graphics, text, font, point, color, TextFormatFlags.Default); TextRenderer.DrawText(graphics, text, font, point, color, TextFormatFlags.Default);
} }
// Draw "NEW" tag after the AppID for newly discovered DLCs
if (dlcType is not DLCType.None)
{
SelectionDLC dlc = SelectionDLC.FromId(dlcType, node.Parent?.Name, id);
if (dlc?.IsNew == true)
{
const string newTag = " NEW";
size = TextRenderer.MeasureText(graphics, newTag, font);
bounds = bounds with { X = bounds.X + bounds.Width + 1, Width = size.Width };
selectionBounds = new(selectionBounds.Location,
selectionBounds.Size + new Size(bounds.Size.Width + 1, 0));
graphics.FillRectangle(brush, bounds);
point = new(bounds.Location.X - 1, bounds.Location.Y + 1);
TextRenderer.DrawText(graphics, newTag, font, point, Color.Orange, TextFormatFlags.Default);
}
}
if (form is MainForm) if (form is MainForm)
{ {
Selection selection = Selection.FromId(platform, id); Selection selection = Selection.FromId(platform, id);
+1 -21
View File
@@ -371,27 +371,7 @@ internal sealed partial class InstallForm : CustomForm
if (unlocker != InstalledUnlocker.None) if (unlocker != InstalledUnlocker.None)
{ {
int dlcCount = selection.DLC.Count(); int dlcCount = selection.DLC.Count();
ProgramData.UpsertInstalledGame(new InstalledGameRecord ProgramData.UpsertInstalledGame(selection.ToInstalledGameRecord());
{
Platform = selection.Platform,
Id = selection.Id,
Name = selection.Name,
RootDirectory = selection.RootDirectory,
Unlocker = unlocker,
UseProxy = selection.UseProxy,
ProxyDllName = selection.UseProxy ? selection.Proxy ?? Selection.DefaultProxy : null,
UseExtraProtection = selection.UseExtraProtection,
Dlc = selection.DLC
.GroupBy(dlc => dlc.Id)
.Select(g => g.First())
.Select(dlc => new InstalledDlcRecord
{
DlcType = dlc.Type.ToString(),
Id = dlc.Id,
Name = dlc.Name,
Enabled = dlc.Enabled
}).ToList()
});
ProgramData.Log.Info($"[InstallForm] Saved to installed.json: {unlocker} with {dlcCount} DLCs | Game: {selection.Name} ({selection.Id})", LogDestination.Unlocker); ProgramData.Log.Info($"[InstallForm] Saved to installed.json: {unlocker} with {dlcCount} DLCs | Game: {selection.Name} ({selection.Id})", LogDestination.Unlocker);
} }
else else
+207 -75
View File
@@ -36,6 +36,9 @@ internal sealed partial class MainForm : CustomForm
private List<(Platform platform, string id, string name)> programsToScan; private List<(Platform platform, string id, string name)> programsToScan;
private const int SteamCmdTimeoutMs = 16000;
private const string DlcRefreshLogPrefix = "[DLCRefresh] ";
private MainForm() private MainForm()
{ {
InitializeComponent(); InitializeComponent();
@@ -129,6 +132,15 @@ internal sealed partial class MainForm : CustomForm
return await task; return await task;
return default; return default;
} }
private static async Task<string> ResolveSteamDlcName(string dlcId, string parentGameName = null, string parentGameId = null)
{
StoreAppData dlcStore = await SteamStore.QueryStoreAPI(dlcId, isDlc: true, attempts: 0, parentGameName, parentGameId);
if (dlcStore?.Name is not null)
return dlcStore.Name;
CmdAppData dlcCmd = await SteamCMD.GetAppInfo(dlcId);
return dlcCmd?.Common?.Name ?? "Unknown";
}
private async Task GetApplicablePrograms(IProgress<int> progress, bool uninstallAll = false) private async Task GetApplicablePrograms(IProgress<int> progress, bool uninstallAll = false)
{ {
if (!uninstallAll && (programsToScan is null || programsToScan.Count < 1)) if (!uninstallAll && (programsToScan is null || programsToScan.Count < 1))
@@ -256,7 +268,7 @@ internal sealed partial class MainForm : CustomForm
_ = Interlocked.Decrement(ref steamGamesToCheck); _ = Interlocked.Decrement(ref steamGamesToCheck);
if (Volatile.Read(ref steamGamesToCheck) == 0) if (Volatile.Read(ref steamGamesToCheck) == 0)
gameQueriesDone.TrySetResult(); gameQueriesDone.TrySetResult();
CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(appId, branch, buildId), 16000); CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(appId, branch, buildId), SteamCmdTimeoutMs);
if (storeAppData is null && cmdAppData is null) if (storeAppData is null && cmdAppData is null)
{ {
ProgramData.Log.Info($"[Steam] Skipping {name} ({appId}): no store data from Steam Store or SteamCMD — unable to determine DLCs", LogDestination.Scan); ProgramData.Log.Info($"[Steam] Skipping {name} ({appId}): no store data from Steam Store or SteamCMD — unable to determine DLCs", LogDestination.Scan);
@@ -307,7 +319,7 @@ internal sealed partial class MainForm : CustomForm
} }
else else
{ {
CmdAppData dlcCmdAppData = await WithTimeout(SteamCMD.GetAppInfo(dlcAppId), 16000); CmdAppData dlcCmdAppData = await SteamCMD.GetAppInfo(dlcAppId);
if (dlcCmdAppData is not null) if (dlcCmdAppData is not null)
{ {
dlcName = dlcCmdAppData.Common?.Name; dlcName = dlcCmdAppData.Common?.Name;
@@ -351,7 +363,7 @@ internal sealed partial class MainForm : CustomForm
if (Program.Canceled) if (Program.Canceled)
return; return;
if (!string.IsNullOrWhiteSpace(fullGameName) && fullGameAppId != dlcAppId && !SelectionDLC.All.Keys.Any(d => d.GameId == appId && d.Id == fullGameAppId)) if (!string.IsNullOrWhiteSpace(fullGameName) && fullGameAppId != dlcAppId && !Selection.FromId(Platform.Steam, appId)?.DLCById.ContainsKey(fullGameAppId) == true)
{ {
SelectionDLC fullGameDlc = SelectionDLC.GetOrCreate( SelectionDLC fullGameDlc = SelectionDLC.GetOrCreate(
fullGameOnSteamStore ? DLCType.Steam : DLCType.SteamHidden, appId, fullGameOnSteamStore ? DLCType.Steam : DLCType.SteamHidden, appId,
@@ -799,8 +811,37 @@ internal sealed partial class MainForm : CustomForm
await SteamCMD.Cleanup(); await SteamCMD.Cleanup();
} }
if (!scan)
{
int loadSteps = 0;
int loadStep = 0;
Progress<int> loadProgress = new();
loadProgress.ProgressChanged += (_, p) =>
{
if (p < 0) loadSteps = -p;
else loadStep = p;
int pc = loadSteps > 0 ? (int)((float)loadStep / loadSteps * 100) : 0;
progressBar.Value = Math.Max(0, Math.Min(pc, 100));
progressLabel.Text = $"Loading games and DLCs from cached data... {pc}%";
};
IProgress<int> loadReporter = loadProgress;
loadReporter.Report(-4);
LoadSelections();
loadReporter.Report(1);
await LoadSavedInstalledGames();
loadReporter.Report(2);
SyncInstallerConfigs();
loadReporter.Report(3);
}
else
{
LoadSelections(); LoadSelections();
await LoadSavedInstalledGames(); await LoadSavedInstalledGames();
SyncInstallerConfigs();
}
if (!scan && Selection.All.Keys.Any(s => s.InstalledUnlocker != InstalledUnlocker.None)) if (!scan && Selection.All.Keys.Any(s => s.InstalledUnlocker != InstalledUnlocker.None))
RefreshNewDLCsForInstalledGames(); RefreshNewDLCsForInstalledGames();
HideProgressBar(); HideProgressBar();
@@ -957,16 +998,17 @@ internal sealed partial class MainForm : CustomForm
_ = items.Add(new ToolStripSeparator()); _ = items.Add(new ToolStripSeparator());
foreach (ContextMenuItem query in queries) foreach (ContextMenuItem query in queries)
_ = items.Add(query); _ = items.Add(query);
_ = items.Add(new ContextMenuItem("Refresh Queries", "Command Prompt", (_, _) => bool isGameNode = selection is not null;
_ = items.Add(new ContextMenuItem(isGameNode ? "Refresh Game Data" : "Refresh DLC Data", "Command Prompt", async (_, _) =>
{ {
appInfoVDF.DeleteFile(); appInfoVDF.DeleteFile();
appInfoCmdJSON.DeleteFile(); appInfoCmdJSON.DeleteFile();
appInfoJSON.DeleteFile(); appInfoJSON.DeleteFile();
cooldown.DeleteFile(); cooldown.DeleteFile();
selection?.Remove(); if (isGameNode)
if (dlc is not null) await RefreshSingleGameData(selection);
dlc.Selection = null; else
OnLoad(true); await RefreshSingleDlcData(dlc);
})); }));
} }
} }
@@ -1158,12 +1200,10 @@ internal sealed partial class MainForm : CustomForm
if (selection.TreeNode.TreeView is null) if (selection.TreeNode.TreeView is null)
_ = selectionTreeView.Nodes.Add(selection.TreeNode); _ = selectionTreeView.Nodes.Add(selection.TreeNode);
// Restore DLC children from saved record, deduplicating by ID // Restore DLC children from saved record
if (record.Dlc != null && record.Dlc.Count > 0) if (record.Dlc != null && record.Dlc.Count > 0)
{ {
foreach (InstalledDlcRecord dlcRecord in record.Dlc foreach (InstalledDlcRecord dlcRecord in record.Dlc)
.GroupBy(d => d.Id)
.Select(g => g.First()))
{ {
if (!Enum.TryParse(dlcRecord.DlcType, out DLCType dlcType)) if (!Enum.TryParse(dlcRecord.DlcType, out DLCType dlcType))
continue; continue;
@@ -1183,6 +1223,37 @@ internal sealed partial class MainForm : CustomForm
} }
} }
/// <summary>Fires a one-time async API query for a config-only DLC; only creates the entry if the API confirms it exists.</summary>
private static void FireConfigDlcApiQuery(MainForm form, Selection selection, string dlcId)
{
_ = Task.Run(async () =>
{
try
{
string apiName = await ResolveSteamDlcName(dlcId, selection.Name, selection.Id);
if (apiName == "Unknown")
apiName = null;
if (!string.IsNullOrEmpty(apiName))
{
if (form is null || form.Disposing || form.IsDisposed)
return;
form.Invoke(delegate
{
if (Program.Canceled)
return;
SelectionDLC dlc = SelectionDLC.GetOrCreate(DLCType.SteamHidden, selection.Id, dlcId, apiName);
dlc.Selection = selection;
dlc.Enabled = true;
});
}
}
catch
{
// Don't create the DLC if the API query fails
}
});
}
/// <summary>Fires background tasks per installed game to check for new DLCs from store APIs that were released since the last install or scan. Any newly discovered DLCs are added to the tree in a disabled (unchecked) state, since they are not yet configured in the unlocker config files.</summary> /// <summary>Fires background tasks per installed game to check for new DLCs from store APIs that were released since the last install or scan. Any newly discovered DLCs are added to the tree in a disabled (unchecked) state, since they are not yet configured in the unlocker config files.</summary>
private static void RefreshNewDLCsForInstalledGames() private static void RefreshNewDLCsForInstalledGames()
{ {
@@ -1202,9 +1273,12 @@ internal sealed partial class MainForm : CustomForm
Stopwatch timer = Stopwatch.StartNew(); Stopwatch timer = Stopwatch.StartNew();
try try
{ {
ProgramData.Log.Info($"[DLCRefresh] Checking for new DLCs on {selection.Platform} game \"{selection.Name}\" ({selection.Id}) ...", LogDestination.Scan); ProgramData.Log.Info($"{DlcRefreshLogPrefix}Checking for new DLCs on {selection.Platform} game \"{selection.Name}\" ({selection.Id}) ...", LogDestination.Scan);
foreach (SelectionDLC dlc in selection.DLC)
dlc.IsNew = false;
HashSet<string> currentDlcIds = []; HashSet<string> currentDlcIds = [];
List<(string id, string name)> newDlcList = []; List<(string id, string name)> newDlcList = [];
List<string> discoveredMessages = [];
if (selection.Platform == Platform.Steam) if (selection.Platform == Platform.Steam)
{ {
@@ -1213,7 +1287,7 @@ internal sealed partial class MainForm : CustomForm
foreach (string dlcId in await SteamStore.ParseDlcAppIds(storeAppData)) foreach (string dlcId in await SteamStore.ParseDlcAppIds(storeAppData))
_ = currentDlcIds.Add(dlcId); _ = currentDlcIds.Add(dlcId);
CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(selection.Id), 16000); CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(selection.Id), SteamCmdTimeoutMs);
if (cmdAppData is not null) if (cmdAppData is not null)
foreach (string dlcId in await SteamCMD.ParseDlcAppIds(cmdAppData)) foreach (string dlcId in await SteamCMD.ParseDlcAppIds(cmdAppData))
_ = currentDlcIds.Add(dlcId); _ = currentDlcIds.Add(dlcId);
@@ -1222,18 +1296,9 @@ internal sealed partial class MainForm : CustomForm
{ {
if (savedDlcIds.Contains(dlcId)) if (savedDlcIds.Contains(dlcId))
continue; continue;
string dlcName = "Unknown"; string dlcName = await ResolveSteamDlcName(dlcId, selection.Name, selection.Id);
StoreAppData dlcStore = await SteamStore.QueryStoreAPI(dlcId, true, 0, selection.Name, selection.Id);
if (dlcStore is not null)
dlcName = dlcStore.Name;
else
{
CmdAppData dlcCmd = await WithTimeout(SteamCMD.GetAppInfo(dlcId), 16000);
if (dlcCmd?.Common?.Name is not null)
dlcName = dlcCmd.Common.Name;
}
newDlcList.Add((dlcId, dlcName)); newDlcList.Add((dlcId, dlcName));
ProgramData.Log.Info($"[DLCRefresh] New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{dlcName}\" ({dlcId})", LogDestination.Scan); discoveredMessages.Add($"{DlcRefreshLogPrefix}New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{dlcName}\" ({dlcId})");
} }
} }
else if (selection.Platform == Platform.Epic) else if (selection.Platform == Platform.Epic)
@@ -1246,7 +1311,7 @@ internal sealed partial class MainForm : CustomForm
if (!savedDlcIds.Contains(id)) if (!savedDlcIds.Contains(id))
{ {
newDlcList.Add((id, name ?? "Unknown")); newDlcList.Add((id, name ?? "Unknown"));
ProgramData.Log.Info($"[DLCRefresh] New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{name ?? "Unknown"}\" ({id})", LogDestination.Scan); discoveredMessages.Add($"{DlcRefreshLogPrefix}New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{name ?? "Unknown"}\" ({id})");
} }
} }
} }
@@ -1260,6 +1325,8 @@ internal sealed partial class MainForm : CustomForm
{ {
if (Program.Canceled) if (Program.Canceled)
return; return;
foreach (string msg in discoveredMessages)
ProgramData.Log.Info(msg, LogDestination.Scan);
foreach ((string id, string name) in newDlcList) foreach ((string id, string name) in newDlcList)
{ {
DLCType dlcType = selection.Platform switch DLCType dlcType = selection.Platform switch
@@ -1270,17 +1337,19 @@ internal sealed partial class MainForm : CustomForm
}; };
SelectionDLC dlc = SelectionDLC.GetOrCreate(dlcType, selection.Id, id, name); SelectionDLC dlc = SelectionDLC.GetOrCreate(dlcType, selection.Id, id, name);
dlc.Selection = selection; dlc.Selection = selection;
dlc.Enabled = false; dlc.Enabled = selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI;
dlc.IsNew = true;
} }
string state = selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI ? "enabled" : "disabled";
ProgramData.Log.Info($"{DlcRefreshLogPrefix}Added {newDlcList.Count} new {state} DLC(s) to the tree for \"{selection.Name}\" ({selection.Id}) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan);
}); });
ProgramData.Log.Info($"[DLCRefresh] Added {newDlcList.Count} new disabled DLC(s) to the tree for \"{selection.Name}\" ({selection.Id}) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan);
} }
else else
ProgramData.Log.Info($"[DLCRefresh] No new DLCs found for \"{selection.Name}\" ({selection.Id}) — {currentDlcIds.Count} total DLCs known in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); ProgramData.Log.Info($"{DlcRefreshLogPrefix}No new DLCs found for \"{selection.Name}\" ({selection.Id}) — {currentDlcIds.Count} total DLCs known in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan);
} }
catch (Exception e) catch (Exception e)
{ {
ProgramData.Log.Info($"[DLCRefresh] Failed to refresh DLCs for \"{selection.Name}\" ({selection.Id}) after {timer.Elapsed.TotalSeconds:F3}s: {e.Message}", LogDestination.Scan); ProgramData.Log.Info($"{DlcRefreshLogPrefix}Failed to refresh DLCs for \"{selection.Name}\" ({selection.Id}) after {timer.Elapsed.TotalSeconds:F3}s: {e.Message}", LogDestination.Scan);
} }
}); });
refreshTasks.Add(task); refreshTasks.Add(task);
@@ -1288,10 +1357,81 @@ internal sealed partial class MainForm : CustomForm
Stopwatch timer = Stopwatch.StartNew(); Stopwatch timer = Stopwatch.StartNew();
await Task.WhenAll(refreshTasks); await Task.WhenAll(refreshTasks);
timer.Stop(); timer.Stop();
ProgramData.Log.Info($"[DLCRefresh] Background DLC refresh completed for {refreshTasks.Count} installed game(s) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); ProgramData.Log.Info($"{DlcRefreshLogPrefix}Background DLC refresh completed for {refreshTasks.Count} installed game(s) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan);
// Persist all selections with unlockers so newly discovered DLCs survive restart
PersistInstalledGames();
}); });
} }
/// <summary>Persists all selections with a detected unlocker to installed.json, preserving existing proxy/extra-protection data so detection does not overwrite prior install state.</summary>
private static void PersistInstalledGames()
{
List<InstalledGameRecord> installedRecords = ProgramData.ReadInstalledGames();
foreach (Selection selection in Selection.All.Keys)
{
if (selection.InstalledUnlocker != InstalledUnlocker.None)
{
InstalledGameRecord existing = installedRecords.FirstOrDefault(r =>
r.Platform == selection.Platform && r.Id == selection.Id);
ProgramData.UpsertInstalledGame(selection.ToInstalledGameRecord(existing));
}
}
}
/// <summary>Re-queries store/SteamCMD data for a single game and adds any newly-discovered DLCs to the tree. Does not remove existing DLCs.</summary>
private static async Task RefreshSingleGameData(Selection selection)
{
if (selection.Platform == Platform.Steam)
{
StoreAppData storeAppData = await SteamStore.QueryStoreAPI(selection.Id);
CmdAppData cmdAppData = await SteamCMD.GetAppInfo(selection.Id);
HashSet<string> currentDlcIds = [];
if (storeAppData is not null)
foreach (string dlcId in await SteamStore.ParseDlcAppIds(storeAppData))
_ = currentDlcIds.Add(dlcId);
if (cmdAppData is not null)
foreach (string dlcId in await SteamCMD.ParseDlcAppIds(cmdAppData))
_ = currentDlcIds.Add(dlcId);
HashSet<string> existingIds = selection.DLC.Select(d => d.Id).ToHashSet();
foreach (string dlcId in currentDlcIds)
{
if (existingIds.Contains(dlcId))
continue;
string dlcName = await ResolveSteamDlcName(dlcId, selection.Name, selection.Id);
SelectionDLC dlc = SelectionDLC.GetOrCreate(DLCType.Steam, selection.Id, dlcId, dlcName);
dlc.Selection = selection;
}
}
else if (selection.Platform == Platform.Epic)
{
List<(string id, string name, string product, string icon, string developer)> catalog =
await EpicStore.QueryCatalog(selection.Id);
HashSet<string> existingIds = selection.DLC.Select(d => d.Id).ToHashSet();
foreach ((string id, string name, string product, string icon, string developer) in catalog)
{
if (existingIds.Contains(id))
continue;
SelectionDLC dlc = SelectionDLC.GetOrCreate(DLCType.Epic, selection.Id, id, name);
dlc.Product = product;
dlc.Icon = icon;
dlc.Publisher = developer;
dlc.Selection = selection;
}
}
}
/// <summary>Re-queries the name for a single DLC and updates it in the tree.</summary>
private static async Task RefreshSingleDlcData(SelectionDLC dlc)
{
if (dlc.Type is DLCType.Steam or DLCType.SteamHidden)
{
string name = await ResolveSteamDlcName(dlc.Id, dlc.Selection?.Name, dlc.Selection?.Id);
if (name != "Unknown")
dlc.Name = name;
}
}
private void OnLoad(object sender, EventArgs _) private void OnLoad(object sender, EventArgs _)
{ {
bool retry = true; bool retry = true;
@@ -1400,6 +1540,26 @@ internal sealed partial class MainForm : CustomForm
ProgramData.WriteExtraProtectionChoices(extraProtectionChoices); ProgramData.WriteExtraProtectionChoices(extraProtectionChoices);
SyncInstallerConfigs();
PersistInstalledGames();
OnProxyChanged();
}
internal void InvalidateGameList() => selectionTreeView.Invalidate();
internal void OnProxyChanged()
{
selectionTreeView.Invalidate();
}
/// <summary>
/// Detect installed unlockers and proxy DLLs, read SmokeAPI/CreamAPI configs, fire API queries for config DLCs,
/// and merge with persisted installed game records. Must run after selections are populated and unlocker detection
/// is meaningful (i.e., after <see cref="LoadSavedInstalledGames"/> on the non-scan path).
/// </summary>
private void SyncInstallerConfigs()
{
// Detect installed unlockers, proxy DLLs, and read config files — grouped per-game // Detect installed unlockers, proxy DLLs, and read config files — grouped per-game
foreach (Selection selection in Selection.All.Keys) foreach (Selection selection in Selection.All.Keys)
{ {
@@ -1437,11 +1597,24 @@ internal sealed partial class MainForm : CustomForm
{ {
foreach (string directory in selection.DllDirectories) foreach (string directory in selection.DllDirectories)
{ {
HashSet<string> enabledIds = CreamAPI.ReadConfigDlcIds(directory); List<(string id, string name)> configDlcs = CreamAPI.ReadConfigDlcs(directory);
if (enabledIds is not null) if (configDlcs is not null)
{ {
HashSet<string> configDlcIds = configDlcs.Select(e => e.id).ToHashSet();
MainForm form = MainForm.Current;
// Sync enabled state for already-known DLCs from the CreamAPI config
foreach (SelectionDLC dlc in selection.DLC) foreach (SelectionDLC dlc in selection.DLC)
dlc.Enabled = enabledIds.Contains(dlc.Id); dlc.Enabled = configDlcIds.Contains(dlc.Id);
// Fire async API queries for config DLCs not already known;
// DLC entries are only created if the API confirms they exist
foreach ((string id, string name) in configDlcs)
{
if (!selection.DLC.Any(d => d.Id == id))
{
if (form is not null && !form.Disposing && !form.IsDisposed && selection.Platform is Platform.Steam)
FireConfigDlcApiQuery(form, selection, id);
}
}
break; break;
} }
} }
@@ -1459,47 +1632,6 @@ internal sealed partial class MainForm : CustomForm
if (selection.InstalledUnlocker == InstalledUnlocker.None && record.Unlocker != InstalledUnlocker.None) if (selection.InstalledUnlocker == InstalledUnlocker.None && record.Unlocker != InstalledUnlocker.None)
selection.InstalledUnlocker = record.Unlocker; selection.InstalledUnlocker = record.Unlocker;
} }
// Persist any selections with a detected unlocker to installed.json, preserving existing
// proxy/extrapolation data from the saved record so detection does not overwrite prior install state
foreach (Selection selection in Selection.All.Keys)
{
if (selection.InstalledUnlocker != InstalledUnlocker.None)
{
InstalledGameRecord existing = installedRecords.FirstOrDefault(r =>
r.Platform == selection.Platform && r.Id == selection.Id);
ProgramData.UpsertInstalledGame(new InstalledGameRecord
{
Platform = selection.Platform,
Id = selection.Id,
Name = selection.Name,
RootDirectory = selection.RootDirectory,
Unlocker = selection.InstalledUnlocker,
UseProxy = existing?.UseProxy ?? false,
ProxyDllName = existing?.UseProxy == true ? existing.ProxyDllName : null,
UseExtraProtection = existing?.UseExtraProtection ?? false,
Dlc = selection.DLC
.GroupBy(dlc => dlc.Id)
.Select(g => g.First())
.Select(dlc => new InstalledDlcRecord
{
DlcType = dlc.Type.ToString(),
Id = dlc.Id,
Name = dlc.Name,
Enabled = dlc.Enabled
}).ToList()
});
}
}
OnProxyChanged();
}
internal void InvalidateGameList() => selectionTreeView.Invalidate();
internal void OnProxyChanged()
{
selectionTreeView.Invalidate();
} }
internal void OnExtraProtectionChanged() internal void OnExtraProtectionChanged()
@@ -11,23 +11,29 @@ internal static partial class SteamCMD
{ {
private const int CooldownGame = 600; private const int CooldownGame = 600;
private const int CooldownDlc = 1200; private const int CooldownDlc = 1200;
private const int WebApiConcurrency = 12;
private static readonly SemaphoreSlim WebApiGate = new(WebApiConcurrency, WebApiConcurrency);
internal static async Task<CmdAppData> QueryWebAPI(string appId, bool isDlc = false, int attempts = 0) internal static async Task<CmdAppData> QueryWebAPI(string appId, bool isDlc = false)
{ {
while (!Program.Canceled) if (Program.Canceled)
{ return null;
attempts++;
string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json"; string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json";
bool cachedExists = cacheFile.FileExists(); bool cachedExists = cacheFile.FileExists();
if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame)) if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame))
{
await WebApiGate.WaitAsync();
try
{ {
(string response, bool permanentFailure) = (string response, bool permanentFailure) =
await HttpClientManager.EnsureGet($"https://api.steamcmd.net/v1/info/{appId}"); await HttpClientManager.EnsureGet($"https://api.steamcmd.net/v1/info/{appId}");
if (permanentFailure) if (permanentFailure)
{ {
ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed for " +
appId + (isDlc ? " (DLC)" : "") + appId + (isDlc ? " (DLC)" : "") +
": Permanent failure, aborting retries", LogDestination.Steam); ": Permanent failure, aborting", LogDestination.Steam);
return null; return null;
} }
if (response is not null) if (response is not null)
@@ -40,44 +46,59 @@ if (permanentFailure)
if (appDetails.Data.Values.Count != 0) if (appDetails.Data.Values.Count != 0)
{ {
CmdAppData data = appDetails.Data.Values.First(); CmdAppData data = appDetails.Data.Values.First();
// If the API returned empty data (common is null),
// don't cache it fall through to the VDF/SteamCMD.exe path.
if (data.Common?.Name is null)
{
ProgramData.Log.Info(
"[SteamAPI] SteamCMD web API returned empty data for " + appId +
(isDlc ? " (DLC)" : ""), LogDestination.Steam);
return null;
}
try try
{ {
cacheFile.WriteFile(JsonConvert.SerializeObject(data, Formatting.Indented)); cacheFile.WriteFile(JsonConvert.SerializeObject(data, Formatting.Indented));
} }
catch (Exception e) catch (Exception e)
{ {
ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed for " +
" for " + appId + (isDlc ? " (DLC)" : "") appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful serialization (" + e.Message + ")", LogDestination.Steam); + ": Unsuccessful serialization (" + e.Message + ")", LogDestination.Steam);
} }
return data; return data;
} }
else else
ProgramData.Log.Info( ProgramData.Log.Info(
"[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId + "[SteamAPI] SteamCMD web API query failed for " + appId +
(isDlc ? " (DLC)" : "") (isDlc ? " (DLC)" : "")
+ ": No data", LogDestination.Steam); + ": No data", LogDestination.Steam);
} }
else else
ProgramData.Log.Info( ProgramData.Log.Info(
"[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId + "[SteamAPI] SteamCMD web API query failed for " + appId +
(isDlc ? " (DLC)" : "") (isDlc ? " (DLC)" : "")
+ ": Status not success (" + appDetails?.Status + ")", LogDestination.Steam); + ": Status not success (" + appDetails?.Status + ")", LogDestination.Steam);
} }
catch (Exception e) catch (Exception e)
{ {
ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed for " +
appId + (isDlc ? " (DLC)" : "") appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful deserialization (" + e.Message + ")", LogDestination.Steam); + ": Unsuccessful deserialization (" + e.Message + ")", LogDestination.Steam);
} }
} }
else else
ProgramData.Log.Info( ProgramData.Log.Info(
"[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId + "[SteamAPI] SteamCMD web API query failed for " + appId +
(isDlc ? " (DLC)" : "") + (isDlc ? " (DLC)" : "") +
": Response null", LogDestination.Steam); ": Response null", LogDestination.Steam);
} }
finally
{
WebApiGate.Release();
}
}
// Fall back to cached data if available
if (cachedExists) if (cachedExists)
try try
{ {
@@ -88,18 +109,6 @@ catch (Exception e)
cacheFile.DeleteFile(); cacheFile.DeleteFile();
} }
if (isDlc)
break;
if (attempts > 3)
{
ProgramData.Log.Info("[SteamAPI] Failed to query SteamCMD web API after 3 tries: " + appId, LogDestination.Steam);
break;
}
int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempts - 1), 10000);
await Task.Delay(delayMs + Random.Shared.Next(0, 1000));
}
return null; return null;
} }
} }
+10 -6
View File
@@ -28,7 +28,9 @@ internal static class CreamAPI
config = directory + @"\cream_api.ini"; config = directory + @"\cream_api.ini";
} }
internal static HashSet<string> ReadConfigDlcIds(string directory) internal static HashSet<string> ReadConfigDlcIds(string directory) => ReadConfigDlcs(directory)?.Select(e => e.id).ToHashSet();
internal static List<(string id, string name)> ReadConfigDlcs(string directory)
{ {
directory.GetCreamApiComponents(out _, out _, out _, out _, out string config); directory.GetCreamApiComponents(out _, out _, out _, out _, out string config);
if (!config.FileExists()) if (!config.FileExists())
@@ -36,7 +38,7 @@ internal static class CreamAPI
try try
{ {
HashSet<string> dlcIds = []; List<(string id, string name)> dlcEntries = [];
string[] lines = File.ReadAllLines(config, Encoding.Default); string[] lines = File.ReadAllLines(config, Encoding.Default);
bool inDlcSection = false; bool inDlcSection = false;
foreach (string line in lines) foreach (string line in lines)
@@ -51,14 +53,16 @@ internal static class CreamAPI
break; break;
if (inDlcSection && trimmed.Contains('=')) if (inDlcSection && trimmed.Contains('='))
{ {
string id = trimmed.Split('=')[0].Trim(); string[] parts = trimmed.Split('=', 2);
string id = parts[0].Trim();
string name = parts.Length > 1 ? parts[1].Trim() : "Unknown";
if (!string.IsNullOrEmpty(id)) if (!string.IsNullOrEmpty(id))
dlcIds.Add(id); dlcEntries.Add((id, name));
} }
} }
ProgramData.Log.Info($"[CreamAPI] Read config: {config} — {dlcIds.Count} DLC", LogDestination.Unlocker); ProgramData.Log.Info($"[CreamAPI] Read config: {config} — {dlcEntries.Count} DLC", LogDestination.Unlocker);
return dlcIds; return dlcEntries;
} }
catch (Exception e) catch (Exception e)
{ {
+22 -2
View File
@@ -28,6 +28,7 @@ internal sealed class Selection : IEquatable<Selection>
internal static readonly ConcurrentDictionary<Selection, byte> All = new(); internal static readonly ConcurrentDictionary<Selection, byte> All = new();
internal readonly ConcurrentDictionary<string, SelectionDLC> DLCById = new();
internal readonly HashSet<string> DllDirectories; internal readonly HashSet<string> DllDirectories;
internal readonly List<(string directory, BinaryType binaryType)> ExecutableDirectories; internal readonly List<(string directory, BinaryType binaryType)> ExecutableDirectories;
internal readonly HashSet<Selection> ExtraSelections = []; internal readonly HashSet<Selection> ExtraSelections = [];
@@ -86,7 +87,26 @@ internal sealed class Selection : IEquatable<Selection>
set => TreeNode.Checked = value; set => TreeNode.Checked = value;
} }
internal IEnumerable<SelectionDLC> DLC => SelectionDLC.All.Keys.Where(dlc => Equals(dlc.Selection, this)); internal IEnumerable<SelectionDLC> DLC => DLCById.Values;
internal InstalledGameRecord ToInstalledGameRecord(InstalledGameRecord existing = null) => new()
{
Platform = Platform,
Id = Id,
Name = Name,
RootDirectory = RootDirectory,
Unlocker = InstalledUnlocker,
UseProxy = existing?.UseProxy ?? UseProxy,
ProxyDllName = (existing?.UseProxy ?? UseProxy) ? (existing?.ProxyDllName ?? Proxy ?? DefaultProxy) : null,
UseExtraProtection = existing?.UseExtraProtection ?? UseExtraProtection,
Dlc = DLC.Select(dlc => new InstalledDlcRecord
{
DlcType = dlc.Type.ToString(),
Id = dlc.Id,
Name = dlc.Name,
Enabled = dlc.Enabled
}).ToList()
};
public bool Equals(Selection other) => other is not null && public bool Equals(Selection other) => other is not null &&
(ReferenceEquals(this, other) || (ReferenceEquals(this, other) ||
@@ -102,7 +122,7 @@ internal sealed class Selection : IEquatable<Selection>
{ {
_ = All.TryRemove(this, out _); _ = All.TryRemove(this, out _);
TreeNode.Remove(); TreeNode.Remove();
foreach (SelectionDLC dlc in DLC) foreach (SelectionDLC dlc in DLCById.Values.ToList())
dlc.Selection = null; dlc.Selection = null;
} }
+24 -12
View File
@@ -19,13 +19,23 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
internal static readonly ConcurrentDictionary<SelectionDLC, byte> All = new(); internal static readonly ConcurrentDictionary<SelectionDLC, byte> All = new();
internal readonly string Id; internal readonly string Id;
internal readonly string Name; private string _name;
internal string Name
{
get => _name;
set
{
_name = value;
TreeNode.Text = value;
}
}
internal readonly TreeNode TreeNode; internal readonly TreeNode TreeNode;
internal readonly DLCType Type; internal readonly DLCType Type;
internal readonly string GameId; internal readonly string GameId;
internal string Icon; internal string Icon;
internal string Product; internal string Product;
internal string Publisher; internal string Publisher;
internal bool IsNew;
private Selection selection; private Selection selection;
private SelectionDLC(DLCType type, string gameId, string id, string name) private SelectionDLC(DLCType type, string gameId, string id, string name)
@@ -33,7 +43,7 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
Type = type; Type = type;
GameId = gameId; GameId = gameId;
Id = id; Id = id;
Name = name; _name = name;
TreeNode = new() { Tag = Type, Name = Id, Text = Name }; TreeNode = new() { Tag = Type, Name = Id, Text = Name };
_ = All.TryAdd(this, 0); _ = All.TryAdd(this, 0);
} }
@@ -51,6 +61,9 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
{ {
if (ReferenceEquals(selection, value)) if (ReferenceEquals(selection, value))
return; return;
// Remove from previous selection's per-game dictionary
if (selection is not null)
_ = selection.DLCById.TryRemove(Id, out _);
selection = value; selection = value;
if (value is null) if (value is null)
{ {
@@ -60,6 +73,7 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
else else
{ {
_ = All.TryAdd(this, default); _ = All.TryAdd(this, default);
_ = value.DLCById.TryAdd(Id, this);
_ = value.TreeNode.Nodes.Add(TreeNode); _ = value.TreeNode.Nodes.Add(TreeNode);
Enabled = Name != "Unknown" && value.Enabled; Enabled = Name != "Unknown" && value.Enabled;
} }
@@ -72,17 +86,15 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
internal static SelectionDLC GetOrCreate(DLCType type, string gameId, string id, string name) internal static SelectionDLC GetOrCreate(DLCType type, string gameId, string id, string name)
{ {
// For Steam DLCs, Steam and SteamHidden represent the same DLC discovered // Search per-selection dictionaries first (authoritative, deduplicated by ID)
// through different code paths. Look up by (gameId, id) ignoring the Steam foreach (Selection s in Selection.All.Keys)
// subtype to prevent duplicate entries for the same DLC. if (s.DLCById.TryGetValue(id, out SelectionDLC existing)
if (type is DLCType.Steam or DLCType.SteamHidden) && existing.GameId == gameId
{ && (existing.Type == type
SelectionDLC existing = All.Keys.FirstOrDefault( || (type is DLCType.Steam or DLCType.SteamHidden
dlc => dlc.GameId == gameId && dlc.Id == id && existing.Type is DLCType.Steam or DLCType.SteamHidden)))
&& dlc.Type is DLCType.Steam or DLCType.SteamHidden);
if (existing is not null)
return existing; return existing;
} // Fall back to the global pool for unassociated DLCs
return FromId(type, gameId, id) ?? new SelectionDLC(type, gameId, id, name); return FromId(type, gameId, id) ?? new SelectionDLC(type, gameId, id, name);
} }