mirror of
https://github.com/FroggMaster/CreamInstaller.git
synced 2026-07-28 14:47:04 -07:00
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:
@@ -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
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ 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 static readonly List<Task> configApiTasks = [];
|
||||||
|
|
||||||
|
private const int SteamCmdTimeoutMs = 16000;
|
||||||
|
private const string DlcRefreshLogPrefix = "[DLCRefresh] ";
|
||||||
|
|
||||||
private MainForm()
|
private MainForm()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -129,6 +134,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 +270,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 +321,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 +365,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,
|
||||||
@@ -800,7 +814,10 @@ internal sealed partial class MainForm : CustomForm
|
|||||||
}
|
}
|
||||||
|
|
||||||
LoadSelections();
|
LoadSelections();
|
||||||
|
await DrainConfigApiTasks();
|
||||||
await LoadSavedInstalledGames();
|
await LoadSavedInstalledGames();
|
||||||
|
SyncInstallerConfigs();
|
||||||
|
await DrainConfigApiTasks();
|
||||||
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 +974,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 +1176,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 +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>
|
/// <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,7 +1264,7 @@ 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)
|
foreach (SelectionDLC dlc in selection.DLC)
|
||||||
dlc.IsNew = false;
|
dlc.IsNew = false;
|
||||||
HashSet<string> currentDlcIds = [];
|
HashSet<string> currentDlcIds = [];
|
||||||
@@ -1216,7 +1278,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);
|
||||||
@@ -1225,18 +1287,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));
|
||||||
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)
|
else if (selection.Platform == Platform.Epic)
|
||||||
@@ -1249,7 +1302,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"));
|
||||||
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;
|
dlc.IsNew = true;
|
||||||
}
|
}
|
||||||
string state = selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI ? "enabled" : "disabled";
|
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
|
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);
|
||||||
@@ -1295,10 +1348,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;
|
||||||
@@ -1407,6 +1531,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)
|
||||||
{
|
{
|
||||||
@@ -1444,11 +1588,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1466,47 +1623,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;
|
||||||
|
|
||||||
|
string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json";
|
||||||
|
bool cachedExists = cacheFile.FileExists();
|
||||||
|
|
||||||
|
if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame))
|
||||||
{
|
{
|
||||||
attempts++;
|
await WebApiGate.WaitAsync();
|
||||||
string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json";
|
try
|
||||||
bool cachedExists = cacheFile.FileExists();
|
|
||||||
if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame))
|
|
||||||
{
|
{
|
||||||
(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,66 +46,69 @@ 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
|
||||||
if (cachedExists)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonConvert.DeserializeObject<CmdAppData>(cacheFile.ReadFile());
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
cacheFile.DeleteFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDlc)
|
|
||||||
break;
|
|
||||||
if (attempts > 3)
|
|
||||||
{
|
{
|
||||||
ProgramData.Log.Info("[SteamAPI] Failed to query SteamCMD web API after 3 tries: " + appId, LogDestination.Steam);
|
WebApiGate.Release();
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,16 @@ 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;
|
||||||
@@ -34,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);
|
||||||
}
|
}
|
||||||
@@ -52,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)
|
||||||
{
|
{
|
||||||
@@ -61,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;
|
||||||
}
|
}
|
||||||
@@ -73,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user