Modify Refresh Queries Option / Reduce API Requests / Better Game and DLC Data Fallbacks

- Change Refresh Queries to "Refresh Game Data" or "Refresh DLC Data" depending on what is right clicked upon
- Change Refresh Queries to only Refresh the selected Game/DLC Data, rather than all games data.
- Remove constant API retries, reduce attempted to 1 it's pointless when we hammer the API to retry over and over again. We get rate limited and even less data is returned. (Should simulatenously improve scanning to some extent
- Limit the amount of parallel API requests that we perform to avoid hammering the API (probably will still get rated limited if you scan a shit load of games though)
- Remove timeout on DLC name queries, was killing SteamCMD before it could resolve the name (Should ensure we get less Unknown DLC data)
- Add shared helper for DLC name resolution, consolidates duplicate StoreAPI and SteamCMD fallback (Again, should hopefully ensure there's less unknown DLC data)
- Add shared method for game record construction, removes duplicate code in install and persist paths
- Store DLCs per-game by ID in memory during scan to prevent duplicate DLC entries from displaying
- Read DLC names from CreamAPI configs alongside IDs for better DLC name matching before API requests
This commit is contained in:
Frog
2026-07-24 01:26:50 -07:00
parent 68d6977214
commit 2dc6fa001e
6 changed files with 291 additions and 151 deletions
+1 -21
View File
@@ -371,27 +371,7 @@ internal sealed partial class InstallForm : CustomForm
if (unlocker != InstalledUnlocker.None)
{
int dlcCount = selection.DLC.Count();
ProgramData.UpsertInstalledGame(new InstalledGameRecord
{
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.UpsertInstalledGame(selection.ToInstalledGameRecord());
ProgramData.Log.Info($"[InstallForm] Saved to installed.json: {unlocker} with {dlcCount} DLCs | Game: {selection.Name} ({selection.Id})", LogDestination.Unlocker);
}
else
+190 -74
View File
@@ -36,6 +36,11 @@ internal sealed partial class MainForm : CustomForm
private List<(Platform platform, string id, string name)> programsToScan;
private static readonly List<Task> configApiTasks = [];
private const int SteamCmdTimeoutMs = 16000;
private const string DlcRefreshLogPrefix = "[DLCRefresh] ";
private MainForm()
{
InitializeComponent();
@@ -129,6 +134,15 @@ internal sealed partial class MainForm : CustomForm
return await task;
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)
{
if (!uninstallAll && (programsToScan is null || programsToScan.Count < 1))
@@ -256,7 +270,7 @@ internal sealed partial class MainForm : CustomForm
_ = Interlocked.Decrement(ref steamGamesToCheck);
if (Volatile.Read(ref steamGamesToCheck) == 0)
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)
{
ProgramData.Log.Info($"[Steam] Skipping {name} ({appId}): no store data from Steam Store or SteamCMD — unable to determine DLCs", LogDestination.Scan);
@@ -307,7 +321,7 @@ internal sealed partial class MainForm : CustomForm
}
else
{
CmdAppData dlcCmdAppData = await WithTimeout(SteamCMD.GetAppInfo(dlcAppId), 16000);
CmdAppData dlcCmdAppData = await SteamCMD.GetAppInfo(dlcAppId);
if (dlcCmdAppData is not null)
{
dlcName = dlcCmdAppData.Common?.Name;
@@ -351,7 +365,7 @@ internal sealed partial class MainForm : CustomForm
if (Program.Canceled)
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(
fullGameOnSteamStore ? DLCType.Steam : DLCType.SteamHidden, appId,
@@ -800,7 +814,10 @@ internal sealed partial class MainForm : CustomForm
}
LoadSelections();
await DrainConfigApiTasks();
await LoadSavedInstalledGames();
SyncInstallerConfigs();
await DrainConfigApiTasks();
if (!scan && Selection.All.Keys.Any(s => s.InstalledUnlocker != InstalledUnlocker.None))
RefreshNewDLCsForInstalledGames();
HideProgressBar();
@@ -957,16 +974,17 @@ internal sealed partial class MainForm : CustomForm
_ = items.Add(new ToolStripSeparator());
foreach (ContextMenuItem query in queries)
_ = 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();
appInfoCmdJSON.DeleteFile();
appInfoJSON.DeleteFile();
cooldown.DeleteFile();
selection?.Remove();
if (dlc is not null)
dlc.Selection = null;
OnLoad(true);
if (isGameNode)
await RefreshSingleGameData(selection);
else
await RefreshSingleDlcData(dlc);
}));
}
}
@@ -1158,12 +1176,10 @@ internal sealed partial class MainForm : CustomForm
if (selection.TreeNode.TreeView is null)
_ = 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)
{
foreach (InstalledDlcRecord dlcRecord in record.Dlc
.GroupBy(d => d.Id)
.Select(g => g.First()))
foreach (InstalledDlcRecord dlcRecord in record.Dlc)
{
if (!Enum.TryParse(dlcRecord.DlcType, out DLCType dlcType))
continue;
@@ -1183,6 +1199,52 @@ 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 task = 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; // explicitly present in CreamAPI config
});
}
}
catch
{
// Don't create the DLC if the API query fails
}
});
lock (configApiTasks)
configApiTasks.Add(task);
}
/// <summary>Waits for all in-flight config API queries to complete so the background refresh has a complete picture of known DLCs.</summary>
private static async Task DrainConfigApiTasks()
{
Task[] tasks;
lock (configApiTasks)
{
tasks = [.. configApiTasks];
configApiTasks.Clear();
}
if (tasks.Length > 0)
await Task.WhenAll(tasks);
}
/// <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()
{
@@ -1202,7 +1264,7 @@ internal sealed partial class MainForm : CustomForm
Stopwatch timer = Stopwatch.StartNew();
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 = [];
@@ -1216,7 +1278,7 @@ internal sealed partial class MainForm : CustomForm
foreach (string dlcId in await SteamStore.ParseDlcAppIds(storeAppData))
_ = 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)
foreach (string dlcId in await SteamCMD.ParseDlcAppIds(cmdAppData))
_ = currentDlcIds.Add(dlcId);
@@ -1225,18 +1287,9 @@ internal sealed partial class MainForm : CustomForm
{
if (savedDlcIds.Contains(dlcId))
continue;
string dlcName = "Unknown";
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;
}
string dlcName = await ResolveSteamDlcName(dlcId, selection.Name, selection.Id);
newDlcList.Add((dlcId, dlcName));
discoveredMessages.Add($"[DLCRefresh] New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{dlcName}\" ({dlcId})");
discoveredMessages.Add($"{DlcRefreshLogPrefix}New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{dlcName}\" ({dlcId})");
}
}
else if (selection.Platform == Platform.Epic)
@@ -1249,7 +1302,7 @@ internal sealed partial class MainForm : CustomForm
if (!savedDlcIds.Contains(id))
{
newDlcList.Add((id, name ?? "Unknown"));
discoveredMessages.Add($"[DLCRefresh] New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{name ?? "Unknown"}\" ({id})");
discoveredMessages.Add($"{DlcRefreshLogPrefix}New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{name ?? "Unknown"}\" ({id})");
}
}
}
@@ -1279,15 +1332,15 @@ internal sealed partial class MainForm : CustomForm
dlc.IsNew = true;
}
string state = selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI ? "enabled" : "disabled";
ProgramData.Log.Info($"[DLCRefresh] 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($"{DlcRefreshLogPrefix}Added {newDlcList.Count} new {state} DLC(s) to the tree for \"{selection.Name}\" ({selection.Id}) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan);
});
}
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)
{
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);
@@ -1295,10 +1348,81 @@ internal sealed partial class MainForm : CustomForm
Stopwatch timer = Stopwatch.StartNew();
await Task.WhenAll(refreshTasks);
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 _)
{
bool retry = true;
@@ -1407,6 +1531,26 @@ internal sealed partial class MainForm : CustomForm
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
foreach (Selection selection in Selection.All.Keys)
{
@@ -1444,11 +1588,24 @@ internal sealed partial class MainForm : CustomForm
{
foreach (string directory in selection.DllDirectories)
{
HashSet<string> enabledIds = CreamAPI.ReadConfigDlcIds(directory);
if (enabledIds is not null)
List<(string id, string name)> configDlcs = CreamAPI.ReadConfigDlcs(directory);
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)
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;
}
}
@@ -1466,47 +1623,6 @@ internal sealed partial class MainForm : CustomForm
if (selection.InstalledUnlocker == InstalledUnlocker.None && record.Unlocker != InstalledUnlocker.None)
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()
@@ -11,23 +11,29 @@ internal static partial class SteamCMD
{
private const int CooldownGame = 600;
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;
string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json";
bool cachedExists = cacheFile.FileExists();
if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame))
{
attempts++;
string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json";
bool cachedExists = cacheFile.FileExists();
if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame))
await WebApiGate.WaitAsync();
try
{
(string response, bool permanentFailure) =
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)" : "") +
": Permanent failure, aborting retries", LogDestination.Steam);
": Permanent failure, aborting", LogDestination.Steam);
return null;
}
if (response is not null)
@@ -40,66 +46,69 @@ if (permanentFailure)
if (appDetails.Data.Values.Count != 0)
{
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
{
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 +
" for " + appId + (isDlc ? " (DLC)" : "")
ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful serialization (" + e.Message + ")", LogDestination.Steam);
}
return data;
}
else
ProgramData.Log.Info(
"[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId +
"[SteamAPI] SteamCMD web API query failed for " + appId +
(isDlc ? " (DLC)" : "")
+ ": No data", LogDestination.Steam);
}
else
ProgramData.Log.Info(
"[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId +
"[SteamAPI] SteamCMD web API query failed for " + appId +
(isDlc ? " (DLC)" : "")
+ ": 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)" : "")
+ ": Unsuccessful deserialization (" + e.Message + ")", LogDestination.Steam);
}
}
else
ProgramData.Log.Info(
"[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId +
"[SteamAPI] SteamCMD web API query failed for " + appId +
(isDlc ? " (DLC)" : "") +
": Response null", LogDestination.Steam);
}
if (cachedExists)
try
{
return JsonConvert.DeserializeObject<CmdAppData>(cacheFile.ReadFile());
}
catch
{
cacheFile.DeleteFile();
}
if (isDlc)
break;
if (attempts > 3)
finally
{
ProgramData.Log.Info("[SteamAPI] Failed to query SteamCMD web API after 3 tries: " + appId, LogDestination.Steam);
break;
WebApiGate.Release();
}
int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempts - 1), 10000);
await Task.Delay(delayMs + Random.Shared.Next(0, 1000));
}
// Fall back to cached data if available
if (cachedExists)
try
{
return JsonConvert.DeserializeObject<CmdAppData>(cacheFile.ReadFile());
}
catch
{
cacheFile.DeleteFile();
}
return null;
}
}
+10 -6
View File
@@ -28,7 +28,9 @@ internal static class CreamAPI
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);
if (!config.FileExists())
@@ -36,7 +38,7 @@ internal static class CreamAPI
try
{
HashSet<string> dlcIds = [];
List<(string id, string name)> dlcEntries = [];
string[] lines = File.ReadAllLines(config, Encoding.Default);
bool inDlcSection = false;
foreach (string line in lines)
@@ -51,14 +53,16 @@ internal static class CreamAPI
break;
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))
dlcIds.Add(id);
dlcEntries.Add((id, name));
}
}
ProgramData.Log.Info($"[CreamAPI] Read config: {config} — {dlcIds.Count} DLC", LogDestination.Unlocker);
return dlcIds;
ProgramData.Log.Info($"[CreamAPI] Read config: {config} — {dlcEntries.Count} DLC", LogDestination.Unlocker);
return dlcEntries;
}
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 readonly ConcurrentDictionary<string, SelectionDLC> DLCById = new();
internal readonly HashSet<string> DllDirectories;
internal readonly List<(string directory, BinaryType binaryType)> ExecutableDirectories;
internal readonly HashSet<Selection> ExtraSelections = [];
@@ -86,7 +87,26 @@ internal sealed class Selection : IEquatable<Selection>
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 &&
(ReferenceEquals(this, other) ||
@@ -102,7 +122,7 @@ internal sealed class Selection : IEquatable<Selection>
{
_ = All.TryRemove(this, out _);
TreeNode.Remove();
foreach (SelectionDLC dlc in DLC)
foreach (SelectionDLC dlc in DLCById.Values.ToList())
dlc.Selection = null;
}
+23 -12
View File
@@ -19,7 +19,16 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
internal static readonly ConcurrentDictionary<SelectionDLC, byte> All = new();
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 DLCType Type;
internal readonly string GameId;
@@ -34,7 +43,7 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
Type = type;
GameId = gameId;
Id = id;
Name = name;
_name = name;
TreeNode = new() { Tag = Type, Name = Id, Text = Name };
_ = All.TryAdd(this, 0);
}
@@ -52,6 +61,9 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
{
if (ReferenceEquals(selection, value))
return;
// Remove from previous selection's per-game dictionary
if (selection is not null)
_ = selection.DLCById.TryRemove(Id, out _);
selection = value;
if (value is null)
{
@@ -61,6 +73,7 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
else
{
_ = All.TryAdd(this, default);
_ = value.DLCById.TryAdd(Id, this);
_ = value.TreeNode.Nodes.Add(TreeNode);
Enabled = Name != "Unknown" && value.Enabled;
}
@@ -73,17 +86,15 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
internal static SelectionDLC GetOrCreate(DLCType type, string gameId, string id, string name)
{
// For Steam DLCs, Steam and SteamHidden represent the same DLC discovered
// through different code paths. Look up by (gameId, id) ignoring the Steam
// subtype to prevent duplicate entries for the same DLC.
if (type is DLCType.Steam or DLCType.SteamHidden)
{
SelectionDLC existing = All.Keys.FirstOrDefault(
dlc => dlc.GameId == gameId && dlc.Id == id
&& dlc.Type is DLCType.Steam or DLCType.SteamHidden);
if (existing is not null)
// Search per-selection dictionaries first (authoritative, deduplicated by ID)
foreach (Selection s in Selection.All.Keys)
if (s.DLCById.TryGetValue(id, out SelectionDLC existing)
&& existing.GameId == gameId
&& (existing.Type == type
|| (type is DLCType.Steam or DLCType.SteamHidden
&& existing.Type is DLCType.Steam or DLCType.SteamHidden)))
return existing;
}
// Fall back to the global pool for unassociated DLCs
return FromId(type, gameId, id) ?? new SelectionDLC(type, gameId, id, name);
}