using System; using System.Collections.Generic; using System.IO; #if NET35_OR_GREATER || NETCOREAPP using System.Linq; #endif using System.Text.RegularExpressions; using System.Threading.Tasks; using BinaryObjectScanner; using SabreTools.IO.Extensions; #pragma warning disable SYSLIB1045 // Convert to 'GeneratedRegexAttribute' namespace MPF.Frontend.Tools { public static class ProtectionTool { /// /// Set of protection prefixes to filter on /// /// Based on Redump requirements private static readonly string[] FilterPrefixes = [ #region Game Engine "RenderWare", #endregion #region Packers ".NET Reactor", "7-Zip SFX", "ASPack", "AutoPlay Media Studio", "Caphyon Advanced Installer", "CExe", "dotFuscator", "Embedded", "EXE Stealth", "Gentee Installer", "GP-Install", "HyperTech CrackProof", "Inno Setup", "InstallAnywhere", "Installer VISE", "Intel Installation Framework", "Microsoft CAB SFX", "MPRESS", "NeoLite", "NSIS", "PE Compact", "PEtite", "Setup Factory", "Shrinker", "UPX", "WinRAR SFX", "WinZip SFX", "Wise Installation", #endregion #region Protections "CD-Key / Serial", "EA CdKey", "Executable-Based CD Check", "Executable-Based Online Registration", #endregion ]; /// /// Run comprehensive protection scans based on both the /// physical media as well as the image /// /// Base output image path /// Drive object representing the current drive /// Options object that determines what to scan /// Optional progress callback public static async Task>> RunCombinedProtectionScans(string basePath, Drive? drive, Options options, IProgress? protectionProgress = null) { // Setup the output protections dictionary Dictionary> protections = []; // Scan the disc image, if possible if (File.Exists($"{basePath}.iso")) { var imageProtections = await RunProtectionScanOnImage($"{basePath}.iso", options, protectionProgress); MergeDictionaries(protections, imageProtections); } else if (File.Exists($"{basePath}.bin")) { var imageProtections = await RunProtectionScanOnImage($"{basePath}.bin", options, protectionProgress); MergeDictionaries(protections, imageProtections); } else if (File.Exists($"{basePath}.cue")) { string[] cueLines = File.ReadAllLines($"{basePath}.cue"); foreach (string cueLine in cueLines) { // Skip all non-FILE lines if (!cueLine.StartsWith("FILE")) continue; // Extract the information var match = Regex.Match(cueLine, @"FILE ""(.*?)"" BINARY"); if (!match.Success || match.Groups.Count == 0) continue; // Get the track name from the matches string trackName = match.Groups[1].Value; trackName = Path.GetFileNameWithoutExtension(trackName); string baseDir = Path.GetDirectoryName(basePath) ?? string.Empty; string trackPath = Path.Combine(baseDir, trackName); // Scan the track for protections, if it exists if (File.Exists($"{trackPath}.bin")) { var trackProtections = await RunProtectionScanOnImage($"{trackPath}.bin", options, protectionProgress); MergeDictionaries(protections, trackProtections); } } } // Scan the mounted drive path if (drive?.Name is not null) { var driveProtections = await RunProtectionScanOnPath(drive.Name, options, protectionProgress); MergeDictionaries(protections, driveProtections); } return protections; } /// /// Run protection scan on a given path /// /// Path to scan for protection /// Options object that determines what to scan /// Optional progress callback /// Set of all detected copy protections with an optional error string public static async Task>> RunProtectionScanOnPath(string path, Options options, IProgress? progress = null) { #if NET40 var found = await Task.Factory.StartNew(() => #else var found = await Task.Run(() => #endif { var scanner = new Scanner( options.Processing.ProtectionScanning.ScanArchivesForProtection, scanContents: true, // Hardcoded value to avoid issues scanPaths: true, // Hardcoded value to avoid issues scanSubdirectories: true, // Hardcoded value to avoid issues options.Processing.ProtectionScanning.IncludeDebugProtectionInformation, progress); return scanner.GetProtections(path); }); // If nothing was returned, return if (found is null || found.Count == 0) return []; // Return the filtered set of protections return found; } /// /// Run protection scan on a disc image /// /// Image path to scan for protection /// Options object that determines what to scan /// Optional progress callback /// Set of all detected copy protections with an optional error string public static async Task>> RunProtectionScanOnImage(string image, Options options, IProgress? progress = null) { #if NET40 var found = await Task.Factory.StartNew(() => #else var found = await Task.Run(() => #endif { var scanner = new Scanner( scanArchives: false, // Disable extracting disc images for now scanContents: false, // Disabled for image scanning scanPaths: false, // Disabled for image scanning scanSubdirectories: false, // Disabled for image scanning options.Processing.ProtectionScanning.IncludeDebugProtectionInformation, progress); return scanner.GetProtections(image); }); // If nothing was returned, return if (found is null || found.Count == 0) return []; // Return the filtered set of protections return found; } /// /// Format found protections to a deduplicated, ordered string /// /// Dictionary of file to list of protection mappings /// Drive object representing the current drive /// Detected protections, if any public static string? FormatProtections(Dictionary>? protections, Drive? drive) { // If the filtered list is empty in some way, return if (protections is null) return "[EXTERNAL SCAN NEEDED]"; else if (protections.Count == 0 && drive?.Name is null) return "Mounted disc path missing [EXTERNAL SCAN NEEDED]"; else if (protections.Count == 0 && drive?.Name is not null) return "None found [OMIT FROM SUBMISSION]"; // Sanitize context-sensitive protections protections = SanitizeContextSensitiveProtections(protections); // Get a list of distinct found protections #if NET20 var protectionValues = new List(); foreach (var value in protections.Values) { if (value.Count == 0) continue; foreach (var prot in value) { if (!protectionValues.Contains(prot)) protectionValues.Add(prot); } } #else var protectionValues = protections .SelectMany(kvp => kvp.Value) .Distinct() .ToList(); #endif // Sanitize and join protections for writing string protectionString = SanitizeFoundProtections(protectionValues); if (string.IsNullOrEmpty(protectionString)) return "None found [OMIT FROM SUBMISSION]"; return protectionString; } /// /// Get the existence of an anti-modchip string from a PlayStation disc, if possible /// /// Path to scan for anti-modchip strings /// Anti-modchip existence if possible, false on error public static async Task GetPlayStationAntiModchipDetected(string? path) { // If there is no valid path if (string.IsNullOrEmpty(path)) return false; #if NET40 return await Task.Factory.StartNew(() => #else return await Task.Run(() => #endif { try { var antiModchip = new BinaryObjectScanner.Protection.PSXAntiModchip(); foreach (string file in path!.SafeGetFiles("*", SearchOption.AllDirectories)) { try { byte[] fileContent = File.ReadAllBytes(file); var protection = antiModchip.CheckContents(file, fileContent, false); if (!string.IsNullOrEmpty(protection)) return true; } catch { } } } catch { } return false; }); } /// /// Sanitize unnecessary protections where context matters /// /// Dictionary of file to list of protection mappings /// Dictionary with all necessary items filtered out public static Dictionary> SanitizeContextSensitiveProtections(Dictionary> protections) { // Setup the output dictionary Dictionary> filtered = []; // Setup a list for keys that need additional processing List foundKeys = []; // Loop through the keys and add relevant ones string[] paths = [.. protections.Keys]; foreach (var path in paths) { if (!protections.TryGetValue(path, out var values) || values is null || values.Count == 0) continue; // Always copy the values if they're valid filtered[path] = values; if (values.Exists(s => s.StartsWith("SecuROM Release Control -"))) foundKeys.Add(path); } // If there are no keys found if (foundKeys.Count == 0) return filtered; // Process the keys as necessary foreach (var key in foundKeys) { // Get all matching paths var matchingPaths = Array.FindAll(paths, s => s != key && s.StartsWith(key)); if (matchingPaths.Length == 0) continue; // Loop through the matching paths foreach (var path in matchingPaths) { if (!filtered.TryGetValue(path, out var values) || values is null || values.Count == 0) continue; if (values.Exists(s => !s.Contains("GitHub") && (s.Contains("SecuROM 7") || s.Contains("SecuROM 8") || s.Contains("SecuROM Content Activation") || s.Contains("SecuROM Data File Activation") || s.Contains("Unlock")))) { filtered.Remove(path); } } } return filtered; } /// /// Sanitize unnecessary protection duplication from output /// /// Enumerable of found protections /// /// This filtering only impacts the information that goes into the single-line /// protection field in the output submission info. The filtering performed by /// this method applies to the needs of Redump and not necessarily any other /// application. The full protection list should be used as a reference in all /// other cases. /// public static string SanitizeFoundProtections(List foundProtections) { // EXCEPTIONS if (foundProtections.Exists(p => p.StartsWith("[Exception opening file"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("[Exception opening file") && !p.StartsWith("[Access issue when opening file")); foundProtections.Add("Exception occurred while scanning [RESCAN NEEDED]"); } if (foundProtections.Exists(p => p.StartsWith("[Access issue when opening file"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("[Exception opening file") && !p.StartsWith("[Access issue when opening file")); foundProtections.Add("Exception occurred while scanning [RESCAN NEEDED]"); } // Filtered prefixes foreach (string prefix in FilterPrefixes) { foundProtections = foundProtections.FindAll(p => !p.StartsWith(prefix)); } // ActiveMARK if (foundProtections.Exists(p => p == "ActiveMARK 5") && foundProtections.Exists(p => p == "ActiveMARK")) { foundProtections = foundProtections.FindAll(p => p != "ActiveMARK"); } // Cactus Data Shield if (foundProtections.Exists(p => Regex.IsMatch(p, @"Cactus Data Shield [0-9]{3} .+", RegexOptions.Compiled)) && foundProtections.Exists(p => p == "Cactus Data Shield 200")) { foundProtections = foundProtections.FindAll(p => p != "Cactus Data Shield 200"); } // Cactus Data Shield / SafeDisc if (foundProtections.Exists(p => p == "Cactus Data Shield 300 (Confirm presence of other CDS-300 files)")) { foundProtections = foundProtections .FindAll(p => p != "Cactus Data Shield 300 (Confirm presence of other CDS-300 files)"); if (foundProtections.Exists(p => !p.StartsWith("SafeDisc"))) foundProtections.Add("Cactus Data Shield 300"); } // CD-Cops if (foundProtections.Exists(p => p == "CD-Cops") && foundProtections.Exists(p => p.StartsWith("CD-Cops") && p.Length > "CD-Cops".Length)) { foundProtections = foundProtections.FindAll(p => p != "CD-Cops"); } // Electronic Arts if (foundProtections.Exists(p => p == "EA DRM Protection") && foundProtections.Exists(p => p.StartsWith("EA DRM Protection") && p.Length > "EA DRM Protection".Length)) { foundProtections = foundProtections.FindAll(p => p != "EA DRM Protection"); } // Games for Windows LIVE if (foundProtections.Exists(p => p == "Games for Windows LIVE") && foundProtections.Exists(p => p.StartsWith("Games for Windows LIVE") && !p.Contains("Zero Day Piracy Protection") && p.Length > "Games for Windows LIVE".Length)) { foundProtections = foundProtections.FindAll(p => p != "Games for Windows LIVE"); } // Impulse Reactor if (foundProtections.Exists(p => p.StartsWith("Impulse Reactor Core Module")) && foundProtections.Exists(p => p == "Impulse Reactor")) { foundProtections = foundProtections.FindAll(p => p != "Impulse Reactor"); } // JoWood X-Prot if (foundProtections.Exists(p => p.StartsWith("JoWood X-Prot"))) { if (foundProtections.Exists(p => Regex.IsMatch(p, @"JoWood X-Prot [0-9]\.[0-9]\.[0-9]\.[0-9]{2}", RegexOptions.Compiled))) { foundProtections = foundProtections.FindAll(p => p != "JoWood X-Prot") .FindAll(p => p != "JoWood X-Prot v1.0-v1.3") .FindAll(p => p != "JoWood X-Prot v1.4+") .FindAll(p => p != "JoWood X-Prot v2"); } else if (foundProtections.Exists(p => p == "JoWood X-Prot v2")) { foundProtections = foundProtections.FindAll(p => p != "JoWood X-Prot") .FindAll(p => p != "JoWood X-Prot v1.0-v1.3") .FindAll(p => p != "JoWood X-Prot v1.4+"); } else if (foundProtections.Exists(p => p == "JoWood X-Prot v1.4+")) { foundProtections = foundProtections.FindAll(p => p != "JoWood X-Prot") .FindAll(p => p != "JoWood X-Prot v1.0-v1.3"); } else if (foundProtections.Exists(p => p == "JoWood X-Prot v1.0-v1.3")) { foundProtections = foundProtections.FindAll(p => p != "JoWood X-Prot"); } } // LaserLok // TODO: Figure this one out // ProtectDISC / VOB ProtectCD/DVD // TODO: Figure this one out // SafeCast // TODO: Figure this one out // SafeDisc if (foundProtections.Exists(p => p.StartsWith("SafeDisc"))) { // Confirmed this set of checks works with Redump entries 10430, 11347, 13230, 18614, 28257, 31149, 31824, 52606, 57721, 58455, // 58573, 62935, 63941, 64255, 65569, 66005, 70504, 73502, 74520, 78048, 79729, 83468, 98589, and 101261. // Best case scenario for SafeDisc 2+: A full SafeDisc version is found in a line starting with "Macrovision Protected Application". // All other SafeDisc detections can be safely scrubbed. if (foundProtections.Exists(p => Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled) && p.Contains("Macrovision Protected Application") && !p.Contains("SRV Tool APP"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !p.StartsWith("Macrovision Security Driver")) .FindAll(p => !p.Contains("SRV Tool APP")) .FindAll(p => p != "SafeDisc") .FindAll(p => !p.StartsWith("Macrovision Protected Application [Version Expunged]")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}-[0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\/4\+", RegexOptions.Compiled)) .FindAll(p => p != "SafeDisc 1/Lite") .FindAll(p => p != "SafeDisc 2+") .FindAll(p => p != "SafeDisc 3+ (DVD)"); foundProtections = foundProtections.ConvertAll(p => p .Replace("Macrovision Protected Application, ", string.Empty) .Replace(", Macrovision Protected Application", string.Empty)); } // Next best case for SafeDisc 2+: A full SafeDisc version is found from the "SafeDisc SRV Tool APP". else if (foundProtections.Exists(p => Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled) && p.Contains("Macrovision Protected Application") && p.Contains("SRV Tool APP"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !p.StartsWith("Macrovision Security Driver")) .FindAll(p => p != "SafeDisc") .FindAll(p => !p.StartsWith("Macrovision Protected Application [Version Expunged]")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}-[0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\/4\+", RegexOptions.Compiled)) .FindAll(p => p != "SafeDisc 1/Lite") .FindAll(p => p != "SafeDisc 2+") .FindAll(p => p != "SafeDisc 3+ (DVD)"); } // Covers specific edge cases where older drivers are erroneously placed in discs with a newer version of SafeDisc, and the specific SafeDisc version is expunged. else if (foundProtections.Exists(p => Regex.IsMatch(p, @"SafeDisc [1-2]\.[0-9]{2}\.[0-9]{3}-[1-2]\.[0-9]{2}\.[0-9]{3}$", RegexOptions.Compiled) || Regex.IsMatch(p, @"SafeDisc [1-2]\.[0-9]{2}\.[0-9]{3}$", RegexOptions.Compiled)) && foundProtections.Exists(p => p == "SafeDisc 3+ (DVD)")) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !p.StartsWith("Macrovision Protected Application [Version Expunged]")) .FindAll(p => !p.StartsWith("Macrovision Security Driver")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [1-2]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [1-2]\.[0-9]{2}\.[0-9]{3}-[1-2]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled)) .FindAll(p => p != "SafeDisc") .FindAll(p => p != "SafeDisc 1/Lite") .FindAll(p => p != "SafeDisc 2+"); } // Best case for SafeDisc 1.X: A full SafeDisc version is found that isn't part of a version range. else if (foundProtections.Exists(p => Regex.IsMatch(p, @"SafeDisc 1\.[0-9]{2}\.[0-9]{3}$", RegexOptions.Compiled) && !Regex.IsMatch(p, @"SafeDisc 1\.[0-9]{2}\.[0-9]{3}-[0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !p.StartsWith("Macrovision Security Driver")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}-[0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)) .FindAll(p => p != "SafeDisc") .FindAll(p => p != "SafeDisc 1") .FindAll(p => p != "SafeDisc 1/Lite"); } // Next best case for SafeDisc 1: A SafeDisc version range is found from "SECDRV.SYS". else if (foundProtections.Exists(p => (p.StartsWith("Macrovision Security Driver") && Regex.IsMatch(p, @"SafeDisc 1\.[0-9]{2}\.[0-9]{3}-[1-2]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled)) || Regex.IsMatch(p, @"SafeDisc 1\.[0-9]{2}\.[0-9]{3}$"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !p.StartsWith("Macrovision Protected Application [Version Expunged]")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc 1\.[0-9]{2}\.[0-9]{3}-[0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled)) .FindAll(p => p != "SafeDisc") .FindAll(p => p != "SafeDisc 1") .FindAll(p => p != "SafeDisc 1/Lite"); foundProtections = foundProtections.ConvertAll(p => p.StartsWith("Macrovision Security Driver") && p.Split('/').Length > 1 ? p.Split('/')[1].TrimStart() : p); } // Next best case for SafeDisc 2+: A SafeDisc version range is found from "SECDRV.SYS". else if (foundProtections.Exists(p => p.StartsWith("Macrovision Security Driver"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !p.StartsWith("Macrovision Protected Application [Version Expunged]")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc 1\.[0-9]{2}\.[0-9]{3}-[0-9]\.[0-9]{2}\.[0-9]{3}", RegexOptions.Compiled)) .FindAll(p => p != "SafeDisc") .FindAll(p => p != "SafeDisc 1") .FindAll(p => p != "SafeDisc 1/Lite") .FindAll(p => p != "SafeDisc 2+") .FindAll(p => p != "SafeDisc 3+ (DVD)"); foundProtections = foundProtections.ConvertAll(p => p.StartsWith("Macrovision Security Driver") && p.Split('/').Length > 1 ? p.Split('/')[1].TrimStart() : p); } // Only SafeDisc Lite is found. else if (foundProtections.Exists(p => p == "SafeDisc Lite")) { foundProtections = foundProtections.FindAll(p => p != "SafeDisc") .FindAll(p => !Regex.IsMatch(p, @"SafeDisc 1\.[0-9]{2}\.[0-9]{3}-1\.[0-9]{2}\.[0-9]{3}\/Lite", RegexOptions.Compiled)); } // Only SafeDisc 3+ is found. else if (foundProtections.Exists(p => p == "SafeDisc 3+ (DVD)")) { foundProtections = foundProtections.FindAll(p => p != "SafeDisc") .FindAll(p => p != "SafeDisc 2+") .FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)); } // Only SafeDisc 2+ is found. else if (foundProtections.Exists(p => p == "SafeDisc 2+")) { foundProtections = foundProtections.FindAll(p => p != "SafeDisc") .FindAll(p => !p.StartsWith("Macrovision Protection File")) .FindAll(p => !Regex.IsMatch(p, @"SafeDisc [0-9]\.[0-9]{2}\.[0-9]{3}\+", RegexOptions.Compiled)); } } // SecuROM if (foundProtections.Exists(p => p.StartsWith("SecuROM Release Control"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("SecuROM Release Control")); foundProtections.Add("SecuROM Release Control"); } // SolidShield // TODO: Figure this one out // StarForce if (foundProtections.Exists(p => p.StartsWith("StarForce"))) { if (foundProtections.Exists(p => Regex.IsMatch(p, @"StarForce [0-9]+\..+", RegexOptions.Compiled))) { foundProtections = foundProtections.FindAll(p => p != "StarForce") .FindAll(p => p != "StarForce 3-5") .FindAll(p => p != "StarForce 5") .FindAll(p => p != "StarForce 5 [Protected Module]"); } else if (foundProtections.Exists(p => p == "StarForce 5 [Protected Module]")) { foundProtections = foundProtections.FindAll(p => p != "StarForce") .FindAll(p => p != "StarForce 3-5") .FindAll(p => p != "StarForce 5"); } else if (foundProtections.Exists(p => p == "StarForce 5")) { foundProtections = foundProtections.FindAll(p => p != "StarForce") .FindAll(p => p != "StarForce 3-5"); } else if (foundProtections.Exists(p => p == "StarForce 3-5")) { foundProtections = foundProtections.FindAll(p => p != "StarForce"); } } if (foundProtections.Exists(p => p.StartsWith("StarForce Keyless"))) { foundProtections = foundProtections.FindAll(p => !p.StartsWith("StarForce Keyless")); foundProtections.Add("StarForce Keyless"); } // Sysiphus if (foundProtections.Exists(p => p == "Sysiphus") && foundProtections.Exists(p => p.StartsWith("Sysiphus") && p.Length > "Sysiphus".Length)) { foundProtections = foundProtections.FindAll(p => p != "Sysiphus"); } // TAGES // TODO: Figure this one out // XCP if (foundProtections.Exists(p => p == "XCP") && foundProtections.Exists(p => p.StartsWith("XCP") && p.Length > "XCP".Length)) { foundProtections = foundProtections.FindAll(p => p != "XCP"); } // Sort and return the protections foundProtections.Sort(); return string.Join(", ", [.. foundProtections]); } /// /// Merge two dictionaries together based on keys /// /// Source dictionary to add to /// Second dictionary to add from /// TODO: Remove from here when IO is updated private static void MergeDictionaries(Dictionary> original, Dictionary> add) { // Ignore if there are no values to append if (add.Count == 0) return; // Loop through and add from the new dictionary foreach (var kvp in add) { // Ignore empty values if (kvp.Value.Count == 0) continue; if (!original.ContainsKey(kvp.Key)) original[kvp.Key] = []; original[kvp.Key].AddRange(kvp.Value); } } } }