From bf3bb19f7000dda5987d569069f3240a98a7644b Mon Sep 17 00:00:00 2001 From: Pew <83255316+Geekmaxxer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:49:01 -0700 Subject: [PATCH 01/10] Update to memory specs --- SynToolkit.SystemInformationTests/Program.cs | 53 +++++++++++++ .../SynToolkit.SystemInformationTests.csproj | 3 + SynToolkit/Models/SystemSpecs.cs | 9 ++- .../Services/CpuZMemoryReportService.cs | 77 +++++++++++++++++++ SynToolkit/Services/CpuZMemoryTimingParser.cs | 70 +++++++++++++++++ .../MemoryModuleInventoryNormalizer.cs | 73 ++++++++++++++++++ SynToolkit/Services/SystemSpecsService.cs | 55 +++++++++++-- SynToolkit/ViewModels/SpecsPageViewModel.cs | 61 ++++++++++++--- SynToolkit/Views/SpecsPage.xaml | 2 +- 9 files changed, 385 insertions(+), 18 deletions(-) create mode 100644 SynToolkit/Services/CpuZMemoryReportService.cs create mode 100644 SynToolkit/Services/CpuZMemoryTimingParser.cs create mode 100644 SynToolkit/Services/MemoryModuleInventoryNormalizer.cs diff --git a/SynToolkit.SystemInformationTests/Program.cs b/SynToolkit.SystemInformationTests/Program.cs index ceb6823..17ac96f 100644 --- a/SynToolkit.SystemInformationTests/Program.cs +++ b/SynToolkit.SystemInformationTests/Program.cs @@ -58,6 +58,9 @@ private static int Main() Run("Malformed legacy profiles are rejected", MalformedLegacyProfilesAreRejected); Run("Oversized legacy profiles are rejected", OversizedLegacyProfileIsRejected); Run("NVIDIA profile export preserves imported settings", NvidiaProfileExportRoundTrips); + Run("Fragmented onboard memory chips are labeled", FragmentedOnboardMemoryChipsAreLabeled); + Run("Identified desktop DIMMs stay separate", IdentifiedDesktopDimmsStaySeparate); + Run("CPU-Z memory timings are parsed", CpuZMemoryTimingsAreParsed); Console.WriteLine(_failures == 0 ? "All SynToolkit service tests passed." @@ -65,6 +68,56 @@ private static int Main() return _failures == 0 ? 0 : 1; } + private static void FragmentedOnboardMemoryChipsAreLabeled() + { + MemoryModuleSpec[] firmwareEntries = Enumerable.Range(0, 8) + .Select(_ => new MemoryModuleSpec(null, 2UL * 1024 * 1024 * 1024, 4800)) + .ToArray(); + + IReadOnlyList normalized = MemoryModuleInventoryNormalizer.Normalize( + firmwareEntries, + 16UL * 1024 * 1024 * 1024 - 320UL * 1024 * 1024); + + Equal(8, normalized.Count, "Individual onboard chips should remain separate rows."); + True( + normalized.All(module => module.Manufacturer == "Onboard Memory Chip"), + "Each unidentified onboard chip should receive the onboard-memory label."); + True( + normalized.All(module => module.CapacityBytes == 2UL * 1024 * 1024 * 1024 && module.SpeedMHz == 4800), + "Each onboard chip must retain its own capacity and reported speed."); + } + + private static void IdentifiedDesktopDimmsStaySeparate() + { + MemoryModuleSpec[] desktopDimms = + { + new("Gold Key Technology Co Ltd", 8UL * 1024 * 1024 * 1024, 3200, IsMemoryStick: true), + new("Gold Key Technology Co Ltd", 8UL * 1024 * 1024 * 1024, 3200, IsMemoryStick: true) + }; + + IReadOnlyList normalized = MemoryModuleInventoryNormalizer.Normalize( + desktopDimms, + 16UL * 1024 * 1024 * 1024); + + Equal(2, normalized.Count, "Normal identified DIMMs must remain separate rows."); + Equal("Slot 1", normalized[0].SlotLabel, "The first stick must receive Slot 1."); + Equal("Slot 2", normalized[1].SlotLabel, "The second stick must receive Slot 2."); + } + + private static void CpuZMemoryTimingsAreParsed() + { + const string report = "Memory Type DDR5\r\n" + + "CAS# latency (CL) 40.0\r\n" + + "RAS# to CAS# delay (tRCD) 40\r\n" + + "RAS# Precharge (tRP) 40\r\n" + + "Cycle Time (tRAS) 77\r\n"; + + CpuZMemoryTimings? timings = CpuZMemoryTimingParser.TryParse(report); + + Equal("DDR5", timings?.MemoryType, "CPU-Z's memory type should be retained."); + Equal("CL40 40-40-40-77", timings?.TimingText, "CPU-Z's live primary timings should be formatted consistently."); + } + private static void OfficialRegistryPath() => Equal( @"SOFTWARE\AME\Playbooks\Applied", diff --git a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj index 493f5d4..90c3458 100644 --- a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj +++ b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj @@ -12,6 +12,7 @@ + @@ -20,6 +21,8 @@ + + diff --git a/SynToolkit/Models/SystemSpecs.cs b/SynToolkit/Models/SystemSpecs.cs index 6263020..506644a 100644 --- a/SynToolkit/Models/SystemSpecs.cs +++ b/SynToolkit/Models/SystemSpecs.cs @@ -6,7 +6,14 @@ public sealed record CpuSpec(string Name, int Cores, int LogicalProcessors, uint public sealed record GpuSpec(string Name, ulong? AdapterRamBytes, string? DriverVersion, string IconPath); - public sealed record MemoryModuleSpec(string? Manufacturer, ulong CapacityBytes, uint? SpeedMHz); + public sealed record MemoryModuleSpec( + string? Manufacturer, + ulong CapacityBytes, + uint? SpeedMHz, + string? MemoryType = null, + string? SlotLabel = null, + string? TimingText = null, + bool IsMemoryStick = false); public sealed record StorageDriveSpec(string Model, ulong SizeBytes, string? MediaType, string? InterfaceType); diff --git a/SynToolkit/Services/CpuZMemoryReportService.cs b/SynToolkit/Services/CpuZMemoryReportService.cs new file mode 100644 index 0000000..9187006 --- /dev/null +++ b/SynToolkit/Services/CpuZMemoryReportService.cs @@ -0,0 +1,77 @@ +#nullable enable + +using System; +using System.Diagnostics; +using System.IO; + +namespace SynToolkit.Services +{ + /// + /// Reads current memory timings from the bundled CPU-Z report once per app session. CPU-Z's + /// documented -txt argument runs in ghost mode, so Specs collection does not open its UI. + /// + internal static class CpuZMemoryReportService + { + private const int ReportTimeoutMilliseconds = 25_000; + private static readonly Lazy CurrentTimings = new(ReadCurrentTimings); + + internal static CpuZMemoryTimings? GetCurrentTimings() => CurrentTimings.Value; + + private static CpuZMemoryTimings? ReadCurrentTimings() + { + string executablePath = Path.Combine(AppContext.BaseDirectory, "assets", "Tools", "cpuz_x64.exe"); + if (!File.Exists(executablePath)) + { + return null; + } + + string reportBasePath = Path.Combine( + Path.GetTempPath(), + "SynToolkit-CpuZ-" + Guid.NewGuid().ToString("N")); + string reportPath = reportBasePath + ".txt"; + try + { + using Process process = new(); + process.StartInfo = new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = Path.GetDirectoryName(executablePath), + UseShellExecute = false, + CreateNoWindow = true + }; + process.StartInfo.ArgumentList.Add("-txt=" + reportBasePath); + + if (!process.Start() || !process.WaitForExit(ReportTimeoutMilliseconds) || !File.Exists(reportPath)) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + return null; + } + + return CpuZMemoryTimingParser.TryParse(File.ReadAllText(reportPath)); + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] CPU-Z memory timing report was unavailable."); + return null; + } + finally + { + try + { + if (File.Exists(reportPath)) + { + File.Delete(reportPath); + } + } + catch (IOException) + { + // CPU-Z's report is temporary and harmless if a third-party scanner still holds it. + } + } + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/CpuZMemoryTimingParser.cs b/SynToolkit/Services/CpuZMemoryTimingParser.cs new file mode 100644 index 0000000..c059ce9 --- /dev/null +++ b/SynToolkit/Services/CpuZMemoryTimingParser.cs @@ -0,0 +1,70 @@ +#nullable enable + +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace SynToolkit.Services +{ + internal sealed record CpuZMemoryTimings(string? MemoryType, string? TimingText); + + internal static class CpuZMemoryTimingParser + { + private static readonly Regex FirstNumberPattern = new(@"\d+(?:[\.,]\d+)?", RegexOptions.Compiled); + + internal static CpuZMemoryTimings? TryParse(string report) + { + if (string.IsNullOrWhiteSpace(report)) + { + return null; + } + + string? memoryType = ReadValue(report, "Memory Type"); + string? casLatency = ReadClockValue(report, "CAS# latency (CL)"); + string? rasToCasDelay = ReadClockValue(report, "RAS# to CAS# delay (tRCD)"); + string? rasPrecharge = ReadClockValue(report, "RAS# Precharge (tRP)"); + string? cycleTime = ReadClockValue(report, "Cycle Time (tRAS)"); + string? timingText = casLatency is null + ? null + : rasToCasDelay is not null && rasPrecharge is not null && cycleTime is not null + ? $"CL{casLatency} {casLatency}-{rasToCasDelay}-{rasPrecharge}-{cycleTime}" + : $"CL{casLatency}"; + + return string.IsNullOrWhiteSpace(memoryType) && timingText is null + ? null + : new CpuZMemoryTimings(memoryType, timingText); + } + + private static string? ReadValue(string report, string label) + { + foreach (string line in report.Split(["\r\n", "\n"], StringSplitOptions.None)) + { + string trimmed = line.TrimStart(); + if (trimmed.StartsWith(label, StringComparison.OrdinalIgnoreCase)) + { + string value = trimmed[label.Length..].Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + } + + return null; + } + + private static string? ReadClockValue(string report, string label) + { + string? rawValue = ReadValue(report, label); + Match match = rawValue is null ? Match.Empty : FirstNumberPattern.Match(rawValue); + if (!match.Success || + !decimal.TryParse( + match.Value.Replace(',', '.'), + NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out decimal value)) + { + return null; + } + + return value.ToString("0.##", CultureInfo.InvariantCulture); + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/MemoryModuleInventoryNormalizer.cs b/SynToolkit/Services/MemoryModuleInventoryNormalizer.cs new file mode 100644 index 0000000..5d5f165 --- /dev/null +++ b/SynToolkit/Services/MemoryModuleInventoryNormalizer.cs @@ -0,0 +1,73 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using SynToolkit.Models; + +namespace SynToolkit.Services +{ + /// + /// Normalizes firmware memory inventories for the Specs display. Some laptops expose each + /// soldered DRAM chip as a separate unidentified Win32_PhysicalMemory entry rather than a + /// module; those entries retain their individual capacity while receiving a useful onboard-memory label. + /// + internal static class MemoryModuleInventoryNormalizer + { + private const int MinimumFragmentCount = 4; + private const ulong Megabyte = 1024UL * 1024UL; + + internal static IReadOnlyList Normalize( + IEnumerable memoryModules, + ulong totalMemoryBytes) + { + List modules = memoryModules.ToList(); + if (ShouldLabelAsOnboardMemoryChips(modules, totalMemoryBytes)) + { + modules = modules + .Select(module => module with { Manufacturer = "Onboard Memory Chip" }) + .ToList(); + } + + int nextSlotNumber = 1; + return modules + .Select(module => module.IsMemoryStick + ? module with { SlotLabel = $"Slot {nextSlotNumber++}" } + : module) + .ToList(); + } + + private static bool ShouldLabelAsOnboardMemoryChips( + IReadOnlyList modules, + ulong totalMemoryBytes) + { + if (totalMemoryBytes == 0 || modules.Count < MinimumFragmentCount || + modules.Any(module => module.CapacityBytes == 0 || module.SpeedMHz is null || + !IsUnidentifiedManufacturer(module.Manufacturer))) + { + return false; + } + + if (modules.Select(module => module.CapacityBytes).Distinct().Skip(1).Any() || + modules.Select(module => module.SpeedMHz).Distinct().Skip(1).Any()) + { + return false; + } + + ulong combinedCapacity = modules.Aggregate(0UL, (total, module) => total + module.CapacityBytes); + ulong difference = combinedCapacity >= totalMemoryBytes + ? combinedCapacity - totalMemoryBytes + : totalMemoryBytes - combinedCapacity; + ulong tolerance = Math.Max(512UL * Megabyte, totalMemoryBytes / 20); + return difference <= tolerance; + } + + private static bool IsUnidentifiedManufacturer(string? manufacturer) + { + return string.IsNullOrWhiteSpace(manufacturer) || + manufacturer.Trim().Equals("Unknown", StringComparison.OrdinalIgnoreCase) || + manufacturer.Trim().Equals("Not specified", StringComparison.OrdinalIgnoreCase) || + manufacturer.Trim().Equals("To be filled by O.E.M.", StringComparison.OrdinalIgnoreCase); + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/SystemSpecsService.cs b/SynToolkit/Services/SystemSpecsService.cs index c60213e..43875cc 100644 --- a/SynToolkit/Services/SystemSpecsService.cs +++ b/SynToolkit/Services/SystemSpecsService.cs @@ -25,11 +25,13 @@ public static SystemSpecsSnapshot GetSnapshot(ISystemInformationService systemIn { SystemInformationSnapshot windowsInfo = systemInformationService.Detect(); + ulong totalMemoryBytes = GetTotalMemoryBytes(); + return new SystemSpecsSnapshot( GetCpu(), GetGpus(), - GetTotalMemoryBytes(), - GetMemoryModules(), + totalMemoryBytes, + GetMemoryModules(totalMemoryBytes), GetStorageDrives(), GetNetworkAdapters(), GetMotherboard(), @@ -192,12 +194,12 @@ private static ulong GetTotalMemoryBytes() return 0; } - private static IReadOnlyList GetMemoryModules() + private static IReadOnlyList GetMemoryModules(ulong totalMemoryBytes) { List modules = new(); try { - using ManagementObjectSearcher searcher = new("SELECT Manufacturer, Capacity, Speed, ConfiguredClockSpeed FROM Win32_PhysicalMemory"); + using ManagementObjectSearcher searcher = new("SELECT Manufacturer, Capacity, Speed, ConfiguredClockSpeed, SMBIOSMemoryType, FormFactor FROM Win32_PhysicalMemory"); foreach (ManagementBaseObject item in searcher.Get()) { using (item) @@ -206,7 +208,16 @@ private static IReadOnlyList GetMemoryModules() ulong capacity = Convert.ToUInt64(item["Capacity"] ?? 0UL); uint? configuredClockSpeed = ReadPositiveUInt32(item["ConfiguredClockSpeed"]); uint? reportedSpeed = ReadPositiveUInt32(item["Speed"]); - modules.Add(new MemoryModuleSpec(manufacturer, capacity, configuredClockSpeed ?? reportedSpeed)); + uint? smbiosMemoryType = ReadUInt32(item["SMBIOSMemoryType"]); + ushort? formFactor = ReadUInt16(item["FormFactor"]); + modules.Add(new MemoryModuleSpec( + manufacturer, + capacity, + configuredClockSpeed ?? reportedSpeed, + GetMemoryTechnology(smbiosMemoryType), + null, + null, + IsMemoryStick(formFactor))); } } } @@ -215,7 +226,25 @@ private static IReadOnlyList GetMemoryModules() App.logger.Warn(exception, "[Specs] Unable to read memory module information via WMI."); } - return modules; + return MemoryModuleInventoryNormalizer.Normalize(modules, totalMemoryBytes); + } + + /// + /// Adds optional live timing data after the WMI snapshot has already been shown. + /// CPU-Z can take several seconds to generate its report, so it must not delay the + /// rest of the Specs tab. + /// + public static IReadOnlyList AddCurrentMemoryTimingDetails( + IReadOnlyList modules) + { + CpuZMemoryTimings? cpuZTimings = CpuZMemoryReportService.GetCurrentTimings(); + return modules + .Select(module => module with + { + MemoryType = module.MemoryType ?? cpuZTimings?.MemoryType, + TimingText = cpuZTimings?.TimingText + }) + .ToList(); } private static IReadOnlyList GetStorageDrives() @@ -340,6 +369,20 @@ private static bool IsUserFacingPhysicalNetworkAdapter(ManagementBaseObject adap _ => "Inactive" }; + private static string? GetMemoryTechnology(uint? smbiosMemoryType) => smbiosMemoryType switch + { + 18 => "DDR1", + 19 => "DDR2", + 20 => "DDR2 FB-DIMM", + 24 => "DDR3", + 26 => "DDR4", + 34 => "DDR5", + 35 => "LPDDR5", + 36 => "LPDDR5X", + _ => null // CPU-Z provides a fallback for future values, including DDR6. + }; + + private static bool IsMemoryStick(ushort? formFactor) => formFactor is 7 or 8 or 11 or 12 or 13 or 15; private static uint? ReadPositiveUInt32(object? value) { if (value is null) diff --git a/SynToolkit/ViewModels/SpecsPageViewModel.cs b/SynToolkit/ViewModels/SpecsPageViewModel.cs index 302364b..2b1f5ec 100644 --- a/SynToolkit/ViewModels/SpecsPageViewModel.cs +++ b/SynToolkit/ViewModels/SpecsPageViewModel.cs @@ -49,6 +49,9 @@ public partial class SpecsPageViewModel : ObservableObject [ObservableProperty] public partial string TotalMemoryText { get; set; } = string.Empty; + [ObservableProperty] + public partial string MemoryDescriptionText { get; set; } = string.Empty; + [ObservableProperty] public partial string WindowsText { get; set; } = string.Empty; @@ -91,6 +94,7 @@ public async Task LoadAsync() : string.Join(" ", new[] { snapshot.Motherboard.Manufacturer, snapshot.Motherboard.Product }.Where(part => !string.IsNullOrWhiteSpace(part))); TotalMemoryText = FormatBytes(snapshot.TotalMemoryBytes); + MemoryDescriptionText = TotalMemoryText; WindowsText = $"{snapshot.WindowsProductName} ({snapshot.WindowsDisplayVersion}, Build {snapshot.WindowsBuild}, {snapshot.Architecture})"; Gpus.Clear(); @@ -105,16 +109,7 @@ public async Task LoadAsync() GraphicsHeaderIcon = GpuDetectionService.GetPrimaryIconPath(snapshot.Gpus); - MemoryModules.Clear(); - foreach (MemoryModuleSpec module in snapshot.MemoryModules) - { - MemoryModules.Add(new MemoryModuleDisplay( - string.IsNullOrWhiteSpace(module.Manufacturer) ? "Unknown manufacturer" : module.Manufacturer!, - module.SpeedMHz.HasValue - ? $"{FormatBytes(module.CapacityBytes)} · {module.SpeedMHz.Value:N0} MHz" - : FormatBytes(module.CapacityBytes))); - } - + DisplayMemoryModules(snapshot.MemoryModules); NetworkAdapters.Clear(); foreach (NetworkAdapterSpec adapter in snapshot.NetworkAdapters) @@ -152,6 +147,9 @@ public async Task LoadAsync() string typeText = string.Join(" / ", new[] { drive.MediaType, drive.InterfaceType }.Where(part => !string.IsNullOrWhiteSpace(part))); StorageDrives.Add(new StorageDriveDisplay(drive.Model, FormatBytes(drive.SizeBytes), typeText)); } + + MemoryDescriptionText = $"{TotalMemoryText} · Loading timing details..."; + _ = LoadMemoryTimingDetailsAsync(snapshot.MemoryModules); } catch (Exception exception) { @@ -166,6 +164,49 @@ public async Task LoadAsync() } + private async Task LoadMemoryTimingDetailsAsync(IReadOnlyList modules) + { + try + { + IReadOnlyList modulesWithTimings = await Task.Run( + () => SystemSpecsService.AddCurrentMemoryTimingDetails(modules)); + DisplayMemoryModules(modulesWithTimings); + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] CPU-Z memory timing report was unavailable."); + } + finally + { + MemoryDescriptionText = TotalMemoryText; + } + } + + private void DisplayMemoryModules(IReadOnlyList modules) + { + MemoryModules.Clear(); + foreach (MemoryModuleSpec module in modules) + { + string manufacturer = string.IsNullOrWhiteSpace(module.Manufacturer) ? "Unknown manufacturer" : module.Manufacturer!; + string header = string.IsNullOrWhiteSpace(module.SlotLabel) + ? manufacturer + : $"{module.SlotLabel} · {manufacturer}"; + List details = new() { FormatBytes(module.CapacityBytes) }; + if (!string.IsNullOrWhiteSpace(module.MemoryType)) + { + details.Add(module.MemoryType!); + } + if (module.SpeedMHz.HasValue) + { + details.Add($"{module.SpeedMHz.Value:N0} MT/s"); + } + if (!string.IsNullOrWhiteSpace(module.TimingText)) + { + details.Add(module.TimingText!); + } + MemoryModules.Add(new MemoryModuleDisplay(header, string.Join(" · ", details))); + } + } private static string FormatNetworkSpeed(ulong bitsPerSecond) { const double gigabit = 1_000_000_000d; diff --git a/SynToolkit/Views/SpecsPage.xaml b/SynToolkit/Views/SpecsPage.xaml index 568bd51..28ee3d3 100644 --- a/SynToolkit/Views/SpecsPage.xaml +++ b/SynToolkit/Views/SpecsPage.xaml @@ -81,7 +81,7 @@ From 9ec1dab6d922b25b3685adb345647c6b22a5af50 Mon Sep 17 00:00:00 2001 From: Pew <83255316+Geekmaxxer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:50:14 -0700 Subject: [PATCH 02/10] Update SpecsPageViewModel.cs --- SynToolkit/ViewModels/SpecsPageViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SynToolkit/ViewModels/SpecsPageViewModel.cs b/SynToolkit/ViewModels/SpecsPageViewModel.cs index 2b1f5ec..b940763 100644 --- a/SynToolkit/ViewModels/SpecsPageViewModel.cs +++ b/SynToolkit/ViewModels/SpecsPageViewModel.cs @@ -148,7 +148,7 @@ public async Task LoadAsync() StorageDrives.Add(new StorageDriveDisplay(drive.Model, FormatBytes(drive.SizeBytes), typeText)); } - MemoryDescriptionText = $"{TotalMemoryText} · Loading timing details..."; + MemoryDescriptionText = $"{TotalMemoryText} · Loading CAS Latency timings..."; _ = LoadMemoryTimingDetailsAsync(snapshot.MemoryModules); } catch (Exception exception) From 2e86138141f49dde3293a2341941dacf55af77db Mon Sep 17 00:00:00 2001 From: Pew <83255316+Geekmaxxer@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:31:29 -0700 Subject: [PATCH 03/10] Updated specs tab for detailed CPU, GPU, RAM, & MoBo info --- SynToolkit.SystemInformationTests/Program.cs | 81 ++++ .../SynToolkit.SystemInformationTests.csproj | 2 + SynToolkit/Models/SystemSpecs.cs | 2 + SynToolkit/Services/CpuUsageSampler.cs | 121 +++++ .../Services/CpuZMemoryReportService.cs | 23 +- .../Services/CpuZProcessorDetailsParser.cs | 195 ++++++++ SynToolkit/Services/GpuZReportParser.cs | 167 +++++++ SynToolkit/Services/GpuZReportService.cs | 86 ++++ SynToolkit/Services/SystemSpecsService.cs | 446 ++++++++++++++++++ SynToolkit/ViewModels/SpecsPageViewModel.cs | 283 ++++++++++- SynToolkit/Views/SpecsPage.xaml | 188 ++++++-- SynToolkit/Views/SpecsPage.xaml.cs | 48 +- 12 files changed, 1590 insertions(+), 52 deletions(-) create mode 100644 SynToolkit/Services/CpuUsageSampler.cs create mode 100644 SynToolkit/Services/CpuZProcessorDetailsParser.cs create mode 100644 SynToolkit/Services/GpuZReportParser.cs create mode 100644 SynToolkit/Services/GpuZReportService.cs diff --git a/SynToolkit.SystemInformationTests/Program.cs b/SynToolkit.SystemInformationTests/Program.cs index 17ac96f..5319aa0 100644 --- a/SynToolkit.SystemInformationTests/Program.cs +++ b/SynToolkit.SystemInformationTests/Program.cs @@ -61,6 +61,9 @@ private static int Main() Run("Fragmented onboard memory chips are labeled", FragmentedOnboardMemoryChipsAreLabeled); Run("Identified desktop DIMMs stay separate", IdentifiedDesktopDimmsStaySeparate); Run("CPU-Z memory timings are parsed", CpuZMemoryTimingsAreParsed); + Run("CPU-Z processor details are parsed", CpuZProcessorDetailsAreParsed); + Run("AMD 3D V-Cache is detected", Amd3dVCacheIsDetected); + Run("GPU-Z card details are parsed", GpuZCardDetailsAreParsed); Console.WriteLine(_failures == 0 ? "All SynToolkit service tests passed." @@ -118,6 +121,84 @@ private static void CpuZMemoryTimingsAreParsed() Equal("CL40 40-40-40-77", timings?.TimingText, "CPU-Z's live primary timings should be formatted consistently."); } + private static void CpuZProcessorDetailsAreParsed() + { + const string report = "Processors Information\r\n" + + "-------------------------------------------------------------------------\r\n" + + "Socket 1\t\tID = 0\r\n" + + "\tNumber of cores\t\t14 (max 14)\r\n" + + "\tNumber of threads\t20 (max 20)\r\n" + + "\tHybrid\t\tyes, 2 coresets\r\n" + + "\tCore Set 0\t\tP-Cores, 6 cores, 12 threads\r\n" + + "\tCore Set 1\t\tE-Cores, 8 cores, 8 threads\r\n" + + "\tManufacturer\t\tGenuineIntel\r\n" + + "\tName\t\tIntel Core i5 13500\r\n" + + "\tCodename\t\tRaptor Lake\r\n" + + "\tPackage (platform ID)\tSocket 1700 LGA\r\n" + + "\tTechnology\t\t10 nm\r\n" + + "\tCPUID\t\t6.F.2\r\n" + + "\tCore Stepping\t\tC0\r\n" + + "\tInstructions sets\tMMX, SSE, AVX2\r\n" + + "\tTDP Limit\t\t65.0 Watts\r\n" + + "\tTjmax\t\t100.0 °C\r\n" + + "\tBase frequency (cores)\t99.8 MHz\r\n" + + "\tL1 Data cache\t\t6 x 48 KB (12-way) + 8 x 32 KB (8-way)\r\n" + + "\tL1 Instruction cache\t6 x 32 KB (8-way) + 8 x 64 KB (8-way)\r\n" + + "\tL2 cache\t\t6 x 1.25 MB (10-way) + 2 x 2 MB (16-way)\r\n" + + "\tL3 cache\t\t24 MB (12-way)\r\n" + + "\tMax turbo ratio\t\t48x\r\n" + + "\tMin operating ratio\t4x\r\n" + + "\tRatio 1 P-Core\t\t48x\r\n" + + "\tRatio 1 E-Core\t\t35x\r\n" + + "\r\nThread dumps\r\n"; + + CpuZProcessorDetails? details = CpuZProcessorDetailsParser.TryParse(report); + + Equal("Raptor Lake", details?.Codename, "CPU-Z's codename should be retained."); + Equal(6, details?.PerformanceCores?.Cores, "P-core count should be parsed."); + Equal(8, details?.EfficientCores?.Cores, "E-core count should be parsed."); + Equal(4790.4m, details?.PerformanceCores?.MaximumFrequencyMHz, "P-core maximum should use the P-core ratio."); + Equal(3493m, details?.EfficientCores?.MaximumFrequencyMHz, "E-core maximum should use the E-core ratio."); + Equal(399.2m, details?.MinimumFrequencyMHz, "Minimum operating frequency should use its ratio."); + Equal("6 × 48 KB + 8 × 32 KB", details?.L1DataCache, "Cache associativity should be omitted while preserving the hybrid cache layout."); + Equal("100 \u00B0C", details?.TemperatureLimit, "Temperature output must use a valid degree symbol."); + } + private static void Amd3dVCacheIsDetected() + { + const string report = "Processors Information\r\n" + + "\tManufacturer\t\tAuthenticAMD\r\n" + + "\tName\t\tAMD Ryzen 7 7800X3D\r\n" + + "\tL3 cache\t\t96 MB (16-way)\r\n" + + "\r\nThread dumps\r\n"; + + CpuZProcessorDetails? details = CpuZProcessorDetailsParser.TryParse(report); + + True(details?.HasAmd3dVCache == true, "An AMD X3D processor must expose its 3D V-Cache designation."); + Equal("96 MB", details?.L3Cache, "The AMD L3 cache capacity should remain visible."); + } + private static void GpuZCardDetailsAreParsed() + { + const string report = "\n" + + "" + + "AMD Radeon RX 9060 XTNavi 44" + + "AMD/ATIGigabyte4" + + "PCIe x16 5.0 @ x16 4.0GDDR6" + + "Hynix12864" + + "1282048" + + "27803320" + + "Enabled111" + + ""; + + IReadOnlyList cards = GpuZReportParser.Parse(report); + GpuZCardDetails card = cards.Single(); + + Equal("AMD Radeon RX 9060 XT", card.CardName, "GPU-Z card names should be retained for WMI matching."); + True(card.Details.Single(detail => detail.Label == "Bus interface").Value.StartsWith("PCIe x16 5.0 @ x16 4.0", StringComparison.Ordinal), "The current PCIe link should be shown."); + Equal("GDDR6 \u00B7 Hynix \u00B7 128-bit", card.Details.Single(detail => detail.Label == "Memory").Value, "Memory type, vendor, and bus width should be combined."); + True(card.Details.Count <= 12, "GPU-Z fields should stay condensed into a compact set of rows."); + Equal("2048 Shader Units \u00B7 64 ROPs \u00B7 128 TMUs", card.Details.Single(detail => detail.Label == "Compute").Value, "Unified shader counts should be labeled as shader units, not CPU-style cores."); + Equal("OpenCL, Ray Tracing, OpenGL", card.Details.Single(detail => detail.Label == "Features").Value, "Enabled GPU-Z features should be shown without unsupported APIs."); + } private static void OfficialRegistryPath() => Equal( @"SOFTWARE\AME\Playbooks\Applied", diff --git a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj index 90c3458..a794184 100644 --- a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj +++ b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj @@ -23,6 +23,8 @@ + + diff --git a/SynToolkit/Models/SystemSpecs.cs b/SynToolkit/Models/SystemSpecs.cs index 506644a..a96be0a 100644 --- a/SynToolkit/Models/SystemSpecs.cs +++ b/SynToolkit/Models/SystemSpecs.cs @@ -28,6 +28,8 @@ public sealed record NetworkAdapterSpec( public sealed record MotherboardSpec(string? Manufacturer, string? Product); + public sealed record MotherboardDetail(string Label, string Value); + public sealed record SystemSpecsSnapshot( CpuSpec? Cpu, System.Collections.Generic.IReadOnlyList Gpus, diff --git a/SynToolkit/Services/CpuUsageSampler.cs b/SynToolkit/Services/CpuUsageSampler.cs new file mode 100644 index 0000000..411de46 --- /dev/null +++ b/SynToolkit/Services/CpuUsageSampler.cs @@ -0,0 +1,121 @@ +#nullable enable + +using System; +using System.Runtime.InteropServices; + +namespace SynToolkit.Services +{ + internal readonly record struct CpuLiveMetrics(uint? UtilizationPercent, uint? AverageFrequencyMHz); + + /// + /// Samples only Windows' aggregate CPU counters. It does not start a process, issue WMI + /// queries, or change any power setting, so it is safe to call from the Specs timer. + /// + internal sealed class CpuUsageSampler + { + private const int ProcessorInformation = 11; + private ulong? _previousIdleTime; + private ulong? _previousKernelTime; + private ulong? _previousUserTime; + + internal CpuLiveMetrics Sample() => new(ReadUtilizationPercent(), ReadAverageFrequencyMHz()); + + private uint? ReadUtilizationPercent() + { + if (!GetSystemTimes(out FileTime idleTime, out FileTime kernelTime, out FileTime userTime)) + { + return null; + } + + ulong idle = ToUInt64(idleTime); + ulong kernel = ToUInt64(kernelTime); + ulong user = ToUInt64(userTime); + if (!_previousIdleTime.HasValue || !_previousKernelTime.HasValue || !_previousUserTime.HasValue) + { + _previousIdleTime = idle; + _previousKernelTime = kernel; + _previousUserTime = user; + return null; + } + + ulong totalDelta = (kernel - _previousKernelTime.Value) + (user - _previousUserTime.Value); + ulong idleDelta = idle - _previousIdleTime.Value; + _previousIdleTime = idle; + _previousKernelTime = kernel; + _previousUserTime = user; + if (totalDelta == 0 || idleDelta > totalDelta) + { + return null; + } + + return (uint)Math.Clamp(Math.Round((totalDelta - idleDelta) * 100d / totalDelta), 0, 100); + } + + private static uint? ReadAverageFrequencyMHz() + { + int processorCount = Math.Max(Environment.ProcessorCount, 1); + int structureSize = Marshal.SizeOf(); + IntPtr buffer = Marshal.AllocHGlobal(checked(structureSize * processorCount)); + try + { + if (CallNtPowerInformation(ProcessorInformation, IntPtr.Zero, 0, buffer, checked((uint)(structureSize * processorCount))) != 0) + { + return null; + } + + ulong totalMHz = 0; + int validProcessors = 0; + for (int index = 0; index < processorCount; index++) + { + IntPtr current = IntPtr.Add(buffer, checked(index * structureSize)); + ProcessorPowerInformation processor = Marshal.PtrToStructure(current); + if (processor.CurrentMhz == 0) + { + continue; + } + + totalMHz += processor.CurrentMhz; + validProcessors++; + } + + return validProcessors == 0 ? null : (uint)(totalMHz / (uint)validProcessors); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static ulong ToUInt64(FileTime value) => ((ulong)value.HighDateTime << 32) | value.LowDateTime; + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetSystemTimes(out FileTime idleTime, out FileTime kernelTime, out FileTime userTime); + + [DllImport("PowrProf.dll")] + private static extern uint CallNtPowerInformation( + int informationLevel, + IntPtr inputBuffer, + uint inputBufferLength, + IntPtr outputBuffer, + uint outputBufferLength); + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime + { + internal uint LowDateTime; + internal uint HighDateTime; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessorPowerInformation + { + internal uint Number; + internal uint MaxMhz; + internal uint CurrentMhz; + internal uint MhzLimit; + internal uint MaxIdleState; + internal uint CurrentIdleState; + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/CpuZMemoryReportService.cs b/SynToolkit/Services/CpuZMemoryReportService.cs index 9187006..2cdaff7 100644 --- a/SynToolkit/Services/CpuZMemoryReportService.cs +++ b/SynToolkit/Services/CpuZMemoryReportService.cs @@ -6,18 +6,22 @@ namespace SynToolkit.Services { + internal sealed record CpuZHardwareReport(CpuZMemoryTimings? MemoryTimings, CpuZProcessorDetails? ProcessorDetails); + /// - /// Reads current memory timings from the bundled CPU-Z report once per app session. CPU-Z's - /// documented -txt argument runs in ghost mode, so Specs collection does not open its UI. + /// Reads one hidden CPU-Z report per app session. Memory and CPU details share that report, + /// which prevents a second CPU-Z process and keeps the initial Specs snapshot responsive. /// internal static class CpuZMemoryReportService { private const int ReportTimeoutMilliseconds = 25_000; - private static readonly Lazy CurrentTimings = new(ReadCurrentTimings); + private static readonly Lazy CurrentReport = new(ReadCurrentReport); + + internal static CpuZMemoryTimings? GetCurrentTimings() => CurrentReport.Value?.MemoryTimings; - internal static CpuZMemoryTimings? GetCurrentTimings() => CurrentTimings.Value; + internal static CpuZProcessorDetails? GetCurrentProcessorDetails() => CurrentReport.Value?.ProcessorDetails; - private static CpuZMemoryTimings? ReadCurrentTimings() + private static CpuZHardwareReport? ReadCurrentReport() { string executablePath = Path.Combine(AppContext.BaseDirectory, "assets", "Tools", "cpuz_x64.exe"); if (!File.Exists(executablePath)) @@ -51,11 +55,14 @@ internal static class CpuZMemoryReportService return null; } - return CpuZMemoryTimingParser.TryParse(File.ReadAllText(reportPath)); + string report = File.ReadAllText(reportPath); + return new CpuZHardwareReport( + CpuZMemoryTimingParser.TryParse(report), + CpuZProcessorDetailsParser.TryParse(report)); } catch (Exception exception) { - App.logger.Debug(exception, "[Specs] CPU-Z memory timing report was unavailable."); + App.logger.Debug(exception, "[Specs] CPU-Z report was unavailable."); return null; } finally @@ -69,7 +76,7 @@ internal static class CpuZMemoryReportService } catch (IOException) { - // CPU-Z's report is temporary and harmless if a third-party scanner still holds it. + // The temporary report is harmless if a third-party scanner still holds it. } } } diff --git a/SynToolkit/Services/CpuZProcessorDetailsParser.cs b/SynToolkit/Services/CpuZProcessorDetailsParser.cs new file mode 100644 index 0000000..8f49e68 --- /dev/null +++ b/SynToolkit/Services/CpuZProcessorDetailsParser.cs @@ -0,0 +1,195 @@ +#nullable enable + +using System; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace SynToolkit.Services +{ + internal sealed record CpuZCoreSet(string Name, int Cores, int Threads, decimal? MaximumFrequencyMHz); + + internal sealed record CpuZProcessorDetails( + string? Manufacturer, + string? Codename, + string? Socket, + string? Technology, + string? Cpuid, + string? Stepping, + string? InstructionSets, + string? ThermalDesignPower, + string? TemperatureLimit, + decimal? MinimumFrequencyMHz, + decimal? MaximumFrequencyMHz, + string? L1DataCache, + string? L1InstructionCache, + string? L2Cache, + string? L3Cache, + CpuZCoreSet? PerformanceCores, + CpuZCoreSet? EfficientCores, + bool HasAmd3dVCache); + + internal static class CpuZProcessorDetailsParser + { + private static readonly Regex FirstNumberPattern = new(@"\d+(?:[\.,]\d+)?", RegexOptions.Compiled); + private static readonly Regex CoreSetPattern = new( + @"^\s*Core Set \d+\s+(?[PE]-Cores),\s*(?\d+) cores,\s*(?\d+) threads", + RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase); + private static readonly Regex CoreRatioPattern = new( + @"^\s*Ratio \d+\s+(?[PE])-Core(?:s)?\s+(?\d+(?:[\.,]\d+)?)x", + RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase); + private static readonly Regex CacheDetailPattern = new(@"\s*\([^)]*\)", RegexOptions.Compiled); + private static readonly Regex CacheMultiplierPattern = new(@"\s+x\s+", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex RepeatedWhitespacePattern = new(@"\s+", RegexOptions.Compiled); + + internal static CpuZProcessorDetails? TryParse(string report) + { + if (string.IsNullOrWhiteSpace(report)) + { + return null; + } + + string processorSection = ExtractProcessorSection(report); + string? manufacturer = ReadValue(processorSection, "Manufacturer"); + string? name = ReadValue(processorSection, "Name"); + string? specification = ReadValue(processorSection, "Specification"); + decimal? baseFrequencyMHz = ReadNumber(processorSection, "Base frequency (cores)"); + decimal? minimumFrequencyMHz = Multiply(baseFrequencyMHz, ReadNumber(processorSection, "Min operating ratio")); + decimal? maximumFrequencyMHz = Multiply( + baseFrequencyMHz, + ReadNumber(processorSection, "Max turbo ratio") ?? ReadNumber(processorSection, "Max non-turbo ratio")); + CpuZCoreSet? performanceCores = ReadCoreSet(processorSection, "P", baseFrequencyMHz); + CpuZCoreSet? efficientCores = ReadCoreSet(processorSection, "E", baseFrequencyMHz); + bool isAmd = (manufacturer?.Contains("AMD", StringComparison.OrdinalIgnoreCase) ?? false) + || (name?.Contains("AMD", StringComparison.OrdinalIgnoreCase) ?? false) + || (specification?.Contains("AMD", StringComparison.OrdinalIgnoreCase) ?? false); + bool hasAmd3dVCache = isAmd && ( + name?.Contains("X3D", StringComparison.OrdinalIgnoreCase) == true + || specification?.Contains("X3D", StringComparison.OrdinalIgnoreCase) == true + || processorSection.Contains("3D V-Cache", StringComparison.OrdinalIgnoreCase)); + + bool hasDetails = manufacturer is not null + || name is not null + || maximumFrequencyMHz.HasValue + || performanceCores is not null + || efficientCores is not null + || ReadValue(processorSection, "L3 cache") is not null; + if (!hasDetails) + { + return null; + } + + return new CpuZProcessorDetails( + manufacturer, + ReadValue(processorSection, "Codename"), + ReadValue(processorSection, "Package (platform ID)"), + ReadValue(processorSection, "Technology"), + ReadValue(processorSection, "CPUID"), + ReadValue(processorSection, "Core Stepping"), + ReadValue(processorSection, "Instructions sets"), + ReadValue(processorSection, "TDP Limit"), + FormatTemperature(ReadValue(processorSection, "Tjmax")), + minimumFrequencyMHz, + maximumFrequencyMHz, + SimplifyCache(ReadValue(processorSection, "L1 Data cache")), + SimplifyCache(ReadValue(processorSection, "L1 Instruction cache")), + SimplifyCache(ReadValue(processorSection, "L2 cache")), + SimplifyCache(ReadValue(processorSection, "L3 cache")), + performanceCores, + efficientCores, + hasAmd3dVCache); + } + + private static CpuZCoreSet? ReadCoreSet(string processorSection, string corePrefix, decimal? baseFrequencyMHz) + { + Match coreSetMatch = CoreSetPattern.Matches(processorSection) + .Cast() + .FirstOrDefault(match => match.Groups["name"].Value.StartsWith(corePrefix, StringComparison.OrdinalIgnoreCase)) + ?? Match.Empty; + if (!coreSetMatch.Success + || !int.TryParse(coreSetMatch.Groups["cores"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out int cores) + || !int.TryParse(coreSetMatch.Groups["threads"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out int threads)) + { + return null; + } + + decimal? maximumFrequencyMHz = CoreRatioPattern.Matches(processorSection) + .Cast() + .Where(match => match.Groups["name"].Value.Equals(corePrefix, StringComparison.OrdinalIgnoreCase)) + .Select(match => ParseDecimal(match.Groups["ratio"].Value)) + .Where(ratio => ratio.HasValue) + .Select(ratio => Multiply(baseFrequencyMHz, ratio)) + .Where(frequency => frequency.HasValue) + .Select(frequency => frequency!.Value) + .DefaultIfEmpty() + .Max(); + + return new CpuZCoreSet( + coreSetMatch.Groups["name"].Value.ToUpperInvariant(), + cores, + threads, + maximumFrequencyMHz == 0 ? null : maximumFrequencyMHz); + } + + private static string ExtractProcessorSection(string report) + { + int start = report.IndexOf("Processors Information", StringComparison.OrdinalIgnoreCase); + if (start < 0) + { + return report; + } + + int end = report.IndexOf("Thread dumps", start, StringComparison.OrdinalIgnoreCase); + return end < 0 ? report[start..] : report[start..end]; + } + + private static string? ReadValue(string report, string label) + { + foreach (string line in report.Split(["\r\n", "\n"], StringSplitOptions.None)) + { + string trimmed = line.TrimStart(); + if (trimmed.StartsWith(label, StringComparison.OrdinalIgnoreCase)) + { + string value = trimmed[label.Length..].Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + } + + return null; + } + + private static decimal? ReadNumber(string report, string label) => ParseDecimal(ReadValue(report, label)); + + private static decimal? ParseDecimal(string? value) + { + Match match = value is null ? Match.Empty : FirstNumberPattern.Match(value); + return match.Success + && decimal.TryParse(match.Value.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out decimal parsed) + ? parsed + : null; + } + + private static decimal? Multiply(decimal? first, decimal? second) => first.HasValue && second.HasValue + ? first.Value * second.Value + : null; + + private static string? FormatTemperature(string? value) + { + decimal? temperature = ParseDecimal(value); + return temperature.HasValue + ? temperature.Value.ToString("0.##", CultureInfo.InvariantCulture) + " \u00B0C" + : null; + } + private static string? SimplifyCache(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return RepeatedWhitespacePattern.Replace( + CacheMultiplierPattern.Replace(CacheDetailPattern.Replace(value, string.Empty), " × "), + " ").Trim(); + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/GpuZReportParser.cs b/SynToolkit/Services/GpuZReportParser.cs new file mode 100644 index 0000000..e7a6945 --- /dev/null +++ b/SynToolkit/Services/GpuZReportParser.cs @@ -0,0 +1,167 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace SynToolkit.Services +{ + internal sealed record GpuZDetail(string Label, string Value); + + internal sealed record GpuZCardDetails(string CardName, IReadOnlyList Details); + + internal static class GpuZReportParser + { + internal static IReadOnlyList Parse(string report) => Parse(XDocument.Parse(report)); + + internal static IReadOnlyList Parse(Stream report) => Parse(XDocument.Load(report)); + + private static IReadOnlyList Parse(XDocument document) => document.Root? + .Elements("card") + .Select(CreateCard) + .Where(card => !string.IsNullOrWhiteSpace(card.CardName)) + .ToList() + ?? []; + + private static GpuZCardDetails CreateCard(XElement card) + { + List details = new(); + Add(details, "GPU", JoinParts( + Value(card, "gpuname"), + Value(card, "vendor"), + Value(card, "subvendor"))); + Add(details, "Silicon", JoinParts( + WithUnit(Value(card, "processsize"), "nm"), + WithUnit(Value(card, "diesize"), "mm\u00B2"), + AddSuffix(FormatMillions(Value(card, "transistors")), " transistors"), + Value(card, "releasedate"))); + Add(details, "Board", JoinParts( + Combine(Value(card, "vendorid"), Value(card, "deviceid"), ":"), + WithPrefix("Revision", Value(card, "gpurevision")), + WithPrefix("UEFI", ToYesNo(Value(card, "biosuefi"))))); + Add(details, "BIOS", Value(card, "biosversion")); + Add(details, "Bus interface", Value(card, "businterface")); + Add(details, "Platform", JoinParts( + WithPrefix("DirectX", Value(card, "directxsupport")), + WithPrefix("Resizable BAR", Value(card, "resizablebar")))); + Add(details, "Memory", JoinParts( + FormatMemory(card), + WithUnit(NonZero(Value(card, "membandwidth")), "GB/s"))); + Add(details, "Compute", JoinParts( + WithSuffix(NonZero(Value(card, "numshadersunified")), " Shader Units"), + WithSuffix(NonZero(Value(card, "numrops")), " ROPs"), + WithSuffix(NonZero(Value(card, "numtmus")), " TMUs"))); + Add(details, "Throughput", JoinParts( + WithUnit(NonZero(Value(card, "fillratepixel")), "GPixel/s"), + WithUnit(NonZero(Value(card, "fillratetexel")), "GTexel/s"))); + Add(details, "Current clocks", JoinParts( + WithPrefix("GPU", WithUnit(NonZero(Value(card, "clockgpu")), "MHz")), + WithPrefix("Memory", WithUnit(NonZero(Value(card, "clockmem")), "MHz")))); + Add(details, "Boost clock", WithUnit(NonZero(Value(card, "clockgpuboost")), "MHz")); + Add(details, "Driver", JoinParts( + Value(card, "driverversion"), + Value(card, "driverdate"), + Value(card, "whql"))); + Add(details, "Features", FormatFeatures(card)); + + return new GpuZCardDetails(Value(card, "cardname") ?? string.Empty, details); + } + + private static string? Value(XElement card, string name) + { + string? value = card.Element(name)?.Value.Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static void Add(ICollection details, string label, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + details.Add(new GpuZDetail(label, value)); + } + } + + private static string? JoinParts(params string?[] values) + { + string[] populatedValues = values.Where(value => !string.IsNullOrWhiteSpace(value)).Cast().ToArray(); + return populatedValues.Length == 0 ? null : string.Join(" \u00B7 ", populatedValues); + } + + private static string? FormatMemory(XElement card) + { + List parts = new(); + AddPart(parts, Value(card, "memtype")); + AddPart(parts, Value(card, "memvendor")); + string? busWidth = NonZero(Value(card, "membuswidth")); + if (busWidth is not null) + { + parts.Add(busWidth + "-bit"); + } + + return parts.Count == 0 ? null : string.Join(" \u00B7 ", parts); + } + + private static string? FormatFeatures(XElement card) + { + (string ElementName, string DisplayName)[] features = + [ + ("cuda", "CUDA"), + ("opencl", "OpenCL"), + ("dxcompute", "DirectCompute"), + ("physx", "PhysX"), + ("dxr", "Ray Tracing"), + ("directml", "DirectML"), + ("opengl", "OpenGL") + ]; + string[] enabled = features + .Where(feature => Value(card, feature.ElementName) == "1") + .Select(feature => feature.DisplayName) + .ToArray(); + return enabled.Length == 0 ? null : string.Join(", ", enabled); + } + + private static string? FormatMillions(string? value) + { + if (!long.TryParse(value, out long millions) || millions == 0) + { + return null; + } + + return millions >= 1000 + ? (millions / 1000d).ToString("0.##", CultureInfo.InvariantCulture) + " billion" + : millions.ToString(CultureInfo.InvariantCulture) + " million"; + } + + private static string? WithUnit(string? value, string unit) => NonZero(value) is string nonZero ? nonZero + " " + unit : null; + + private static string? WithPrefix(string prefix, string? value) => value is null ? null : prefix + " " + value; + + private static string? WithSuffix(string? value, string suffix) => value is null ? null : value + suffix; + + private static string? AddSuffix(string? value, string suffix) => value is null ? null : value + suffix; + + private static string? ToYesNo(string? value) => value switch + { + "1" => "Yes", + "0" => "No", + _ => null + }; + + private static string? NonZero(string? value) => value is "0" or "0.0" ? null : value; + + private static string? Combine(string? first, string? second, string separator) => first is not null && second is not null + ? first + separator + second + : first ?? second; + + private static void AddPart(ICollection values, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + values.Add(value); + } + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/GpuZReportService.cs b/SynToolkit/Services/GpuZReportService.cs new file mode 100644 index 0000000..904e03c --- /dev/null +++ b/SynToolkit/Services/GpuZReportService.cs @@ -0,0 +1,86 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace SynToolkit.Services +{ + /// + /// Reads one hidden GPU-Z XML dump on demand. Its result is shared by all GPU expanders, so + /// Specs does not launch GPU-Z until a user explicitly opens a card's details. + /// + internal static class GpuZReportService + { + private const int ReportTimeoutMilliseconds = 45_000; + private static readonly Lazy> CurrentReport = new(ReadCurrentReport); + + internal static GpuZCardDetails? GetDetailsFor(string gpuName) + { + IReadOnlyList cards = CurrentReport.Value; + return cards.FirstOrDefault(card => card.CardName.Equals(gpuName, StringComparison.OrdinalIgnoreCase)) + ?? cards.FirstOrDefault(card => card.CardName.Contains(gpuName, StringComparison.OrdinalIgnoreCase) + || gpuName.Contains(card.CardName, StringComparison.OrdinalIgnoreCase)); + } + + private static IReadOnlyList ReadCurrentReport() + { + string executablePath = Path.Combine(AppContext.BaseDirectory, "assets", "Tools", "GPU-Z.2.70.0.exe"); + if (!File.Exists(executablePath)) + { + return []; + } + + string reportPath = Path.Combine( + Path.GetTempPath(), + "SynToolkit-GpuZ-" + Guid.NewGuid().ToString("N") + ".xml"); + try + { + using Process process = new(); + process.StartInfo = new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = Path.GetDirectoryName(executablePath), + UseShellExecute = false, + CreateNoWindow = true + }; + process.StartInfo.ArgumentList.Add("-dump"); + process.StartInfo.ArgumentList.Add(reportPath); + + if (!process.Start() || !process.WaitForExit(ReportTimeoutMilliseconds) || !File.Exists(reportPath)) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + return []; + } + + using FileStream reportStream = File.OpenRead(reportPath); + return GpuZReportParser.Parse(reportStream); + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] GPU-Z report was unavailable."); + return []; + } + finally + { + try + { + if (File.Exists(reportPath)) + { + File.Delete(reportPath); + } + } + catch (IOException) + { + // The temporary report is harmless if a third-party scanner still holds it. + } + } + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/SystemSpecsService.cs b/SynToolkit/Services/SystemSpecsService.cs index 43875cc..57cb82f 100644 --- a/SynToolkit/Services/SystemSpecsService.cs +++ b/SynToolkit/Services/SystemSpecsService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Management; using Microsoft.Win32; @@ -415,6 +416,451 @@ private static bool IsUserFacingPhysicalNetworkAdapter(ManagementBaseObject adap ulong? speed = ReadPositiveUInt64(value); return speed is null or >= (ulong)long.MaxValue ? null : speed; } + /// + /// Reads the fuller firmware-provided motherboard inventory only after the user opens + /// the Motherboard expander. Empty physical sockets are shown only when firmware reports + /// them; Windows does not expose a dependable inventory for every unpopulated NVMe socket. + /// + public static IReadOnlyList GetMotherboardDetails() + { + List details = new(); + AddBaseboardDetails(details); + AddFirmwareDetails(details); + AddProcessorSocketDetails(details); + AddMemorySlotDetails(details); + AddExpansionSlotDetails(details); + AddNvmeDeviceDetails(details); + AddChassisDetails(details); + return details; + } + + private static void AddBaseboardDetails(ICollection details) + { + try + { + using ManagementObjectSearcher searcher = new( + "SELECT Manufacturer, Product, Version, SerialNumber, Tag FROM Win32_BaseBoard"); + foreach (ManagementBaseObject item in searcher.Get()) + { + using (item) + { + AddDetail(details, "Board manufacturer", ReadText(item, "Manufacturer")); + AddDetail(details, "Board model", ReadText(item, "Product")); + AddDetail(details, "Board revision", ReadText(item, "Version")); + AddDetail(details, "Board serial number", ReadText(item, "SerialNumber")); + AddDetail(details, "Board asset tag", ReadText(item, "Tag")); + return; + } + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read detailed motherboard information via WMI."); + } + } + + private static void AddFirmwareDetails(ICollection details) + { + try + { + using ManagementObjectSearcher searcher = new( + "SELECT Manufacturer, SMBIOSBIOSVersion, Version, ReleaseDate, SMBIOSMajorVersion, SMBIOSMinorVersion FROM Win32_BIOS"); + foreach (ManagementBaseObject item in searcher.Get()) + { + using (item) + { + AddDetail(details, "BIOS manufacturer", ReadText(item, "Manufacturer")); + AddDetail( + details, + "BIOS version", + ReadText(item, "SMBIOSBIOSVersion") ?? ReadText(item, "Version")); + AddDetail(details, "BIOS release date", FormatWmiDate(ReadText(item, "ReleaseDate"))); + + uint? major = ReadPositiveUInt32(item["SMBIOSMajorVersion"]); + uint? minor = ReadPositiveUInt32(item["SMBIOSMinorVersion"]); + if (major.HasValue) + { + AddDetail( + details, + "SMBIOS version", + minor.HasValue + ? $"{major.Value}.{minor.Value}" + : major.Value.ToString(CultureInfo.InvariantCulture)); + } + + break; + } + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read BIOS information via WMI."); + } + + AddDetail(details, "Firmware mode", GetFirmwareMode()); + } + + private static void AddProcessorSocketDetails(ICollection details) + { + try + { + using ManagementObjectSearcher searcher = new("SELECT SocketDesignation FROM Win32_Processor"); + string sockets = string.Join( + ", ", + searcher.Get() + .Cast() + .Select(item => + { + using (item) + { + return ReadText(item, "SocketDesignation"); + } + }) + .Where(socket => !string.IsNullOrWhiteSpace(socket)) + .Distinct(StringComparer.OrdinalIgnoreCase)); + AddDetail(details, "CPU socket", sockets); + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read CPU socket information via WMI."); + } + } + + private static void AddMemorySlotDetails(ICollection details) + { + int? slotCount = null; + try + { + using ManagementObjectSearcher arraySearcher = new("SELECT MemoryDevices FROM Win32_PhysicalMemoryArray"); + int reportedSlotCount = 0; + foreach (ManagementBaseObject item in arraySearcher.Get()) + { + using (item) + { + uint? memoryDevices = ReadPositiveUInt32(item["MemoryDevices"]); + if (memoryDevices.HasValue) + { + reportedSlotCount += checked((int)memoryDevices.Value); + } + } + } + + if (reportedSlotCount > 0) + { + slotCount = reportedSlotCount; + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read memory slot count via WMI."); + } + + List populatedSlots = new(); + try + { + using ManagementObjectSearcher memorySearcher = new( + "SELECT DeviceLocator, BankLabel, Capacity, SMBIOSMemoryType, ConfiguredClockSpeed, Speed FROM Win32_PhysicalMemory"); + int unnamedModuleNumber = 0; + foreach (ManagementBaseObject item in memorySearcher.Get()) + { + using (item) + { + string locator = ReadText(item, "DeviceLocator") ?? $"Memory module {++unnamedModuleNumber}"; + string? bank = ReadText(item, "BankLabel"); + ulong capacity = item["Capacity"] is object rawCapacity ? Convert.ToUInt64(rawCapacity) : 0UL; + uint? speed = ReadPositiveUInt32(item["ConfiguredClockSpeed"]) ?? ReadPositiveUInt32(item["Speed"]); + string? memoryType = GetMemoryTechnology(ReadUInt32(item["SMBIOSMemoryType"])); + + List parts = new() { locator }; + if (!string.IsNullOrWhiteSpace(bank) && !string.Equals(bank, locator, StringComparison.OrdinalIgnoreCase)) + { + parts.Add(bank!); + } + + if (capacity > 0) + { + parts.Add(FormatGigabytes(capacity)); + } + + if (!string.IsNullOrWhiteSpace(memoryType)) + { + parts.Add(memoryType!); + } + + if (speed.HasValue) + { + parts.Add($"{speed.Value:N0} MT/s"); + } + + populatedSlots.Add(string.Join(" \u00B7 ", parts)); + } + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read populated memory slots via WMI."); + } + + if (slotCount.HasValue || populatedSlots.Count > 0) + { + string summary = slotCount.HasValue + ? $"{populatedSlots.Count} / {slotCount.Value} populated" + : $"{populatedSlots.Count} populated"; + AddDetail(details, "Memory slots", summary); + } + + foreach (string populatedSlot in populatedSlots.OrderBy(slot => slot, StringComparer.OrdinalIgnoreCase)) + { + AddDetail(details, "Memory slot", populatedSlot); + } + } + + private static void AddExpansionSlotDetails(ICollection details) + { + List<(string Label, string Value, bool InUse)> pcieSlots = new(); + List<(string Label, string Value, bool InUse)> m2Slots = new(); + try + { + using ManagementObjectSearcher searcher = new( + "SELECT SlotDesignation, SlotType, CurrentUsage, MaxDataWidth FROM Win32_SystemSlot"); + foreach (ManagementBaseObject item in searcher.Get()) + { + using (item) + { + string designation = ReadText(item, "SlotDesignation") ?? "Unnamed slot"; + ushort? slotType = ReadUInt16(item["SlotType"]); + ushort? maxDataWidth = ReadUInt16(item["MaxDataWidth"]); + ushort? currentUsage = ReadUInt16(item["CurrentUsage"]); + string? pcieType = GetPcieSlotType(slotType); + bool isM2 = designation.Contains("M.2", StringComparison.OrdinalIgnoreCase) + || designation.Contains("M2", StringComparison.OrdinalIgnoreCase) + || designation.Contains("NVME", StringComparison.OrdinalIgnoreCase); + bool isPcie = pcieType is not null + || designation.Contains("PCI", StringComparison.OrdinalIgnoreCase); + if (!isM2 && !isPcie) + { + continue; + } + + string interfaceText = pcieType ?? "PCI Express"; + if (!interfaceText.Contains(" x", StringComparison.OrdinalIgnoreCase) + && maxDataWidth is > 0) + { + interfaceText += $" x{maxDataWidth.Value}"; + } + + string value = string.Join(" \u00B7 ", new[] + { + designation, + interfaceText, + GetSlotUsage(currentUsage) + }); + (isM2 ? m2Slots : pcieSlots).Add((isM2 ? "M.2 slot" : "PCIe slot", value, currentUsage == 4)); + } + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read expansion slots via WMI."); + } + + foreach ((string label, string value, _) in pcieSlots + .OrderByDescending(slot => slot.InUse) + .ThenBy(slot => slot.Value, StringComparer.OrdinalIgnoreCase)) + { + AddDetail(details, label, value); + } + + foreach ((string label, string value, _) in m2Slots + .OrderByDescending(slot => slot.InUse) + .ThenBy(slot => slot.Value, StringComparer.OrdinalIgnoreCase)) + { + AddDetail(details, label, value); + } + } + + private static void AddNvmeDeviceDetails(ICollection details) + { + try + { + using ManagementObjectSearcher searcher = new( + "SELECT Model, FirmwareRevision, PNPDeviceID, InterfaceType FROM Win32_DiskDrive"); + foreach (ManagementBaseObject item in searcher.Get()) + { + using (item) + { + string? model = ReadText(item, "Model"); + string? pnpDeviceId = ReadText(item, "PNPDeviceID"); + string? interfaceType = ReadText(item, "InterfaceType"); + bool isNvme = (pnpDeviceId?.Contains("NVME", StringComparison.OrdinalIgnoreCase) ?? false) + || (model?.Contains("NVME", StringComparison.OrdinalIgnoreCase) ?? false) + || string.Equals(interfaceType, "NVMe", StringComparison.OrdinalIgnoreCase); + if (!isNvme) + { + continue; + } + + List parts = new() { model ?? "Unknown NVMe drive" }; + string? firmware = ReadText(item, "FirmwareRevision"); + if (!string.IsNullOrWhiteSpace(firmware)) + { + parts.Add($"Firmware {firmware}"); + } + + AddDetail(details, "NVMe drive", string.Join(" \u00B7 ", parts)); + } + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read NVMe storage information via WMI."); + } + } + + private static void AddChassisDetails(ICollection details) + { + try + { + using ManagementObjectSearcher searcher = new("SELECT ChassisTypes FROM Win32_SystemEnclosure"); + foreach (ManagementBaseObject item in searcher.Get()) + { + using (item) + { + if (item["ChassisTypes"] is not Array chassisTypes || chassisTypes.Length == 0) + { + continue; + } + + ushort chassisType = Convert.ToUInt16(chassisTypes.GetValue(0), CultureInfo.InvariantCulture); + AddDetail(details, "System form factor", GetChassisType(chassisType)); + return; + } + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read chassis information via WMI."); + } + } + + private static void AddDetail(ICollection details, string label, string? value) + { + string? cleanValue = CleanText(value); + if (!string.IsNullOrWhiteSpace(cleanValue)) + { + details.Add(new MotherboardDetail(label, cleanValue)); + } + } + + private static string? ReadText(ManagementBaseObject item, string propertyName) => CleanText(item[propertyName]); + + private static string? CleanText(object? rawValue) + { + string value = Convert.ToString(rawValue, CultureInfo.InvariantCulture)?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(value) + || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "None", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "N/A", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "Not Specified", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "Default string", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "System Serial Number", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("To Be Filled By O.E.M.", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return value; + } + + private static string? FormatWmiDate(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length < 8) + { + return null; + } + + return DateTime.TryParseExact( + value[..8], + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime releaseDate) + ? releaseDate.ToString("MMM d, yyyy", CultureInfo.InvariantCulture) + : value; + } + + private static string? GetFirmwareMode() + { + try + { + using RegistryKey? controlKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control"); + return ReadUInt32(controlKey?.GetValue("PEFirmwareType")) switch + { + 1 => "Legacy BIOS", + 2 => "UEFI", + _ => null + }; + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Unable to read firmware mode from the registry."); + return null; + } + } + + private static string GetSlotUsage(ushort? currentUsage) => currentUsage switch + { + 3 => "Available", + 4 => "In use", + 5 => "Unavailable", + _ => "Usage not reported" + }; + + private static string? GetPcieSlotType(ushort? slotType) + { + if (slotType is >= 165 and <= 170) + { + return FormatPcieSlotType(null, slotType.Value - 165); + } + + if (slotType is >= 171 and <= 200) + { + int offset = slotType.Value - 171; + return FormatPcieSlotType(2 + offset / 6, offset % 6); + } + + return null; + } + + private static string FormatPcieSlotType(int? generation, int widthIndex) + { + int[] widths = { 0, 1, 2, 4, 8, 16 }; + string value = generation.HasValue ? $"PCIe Gen {generation.Value}" : "PCI Express"; + return widths[widthIndex] == 0 ? value : $"{value} x{widths[widthIndex]}"; + } + + private static string? GetChassisType(ushort chassisType) => chassisType switch + { + 3 => "Desktop", + 4 => "Low-profile desktop", + 5 => "Pizza-box desktop", + 6 => "Mini tower", + 7 => "Tower", + 8 => "Portable", + 9 => "Laptop", + 10 => "Notebook", + 14 => "Sub-notebook", + 30 => "Tablet", + 31 => "Convertible", + 32 => "Detachable", + _ => null + }; + + private static string FormatGigabytes(ulong bytes) + { + const double gigabyte = 1024d * 1024 * 1024; + return (bytes / gigabyte).ToString("0.##", CultureInfo.InvariantCulture) + " GB"; + } private static MotherboardSpec? GetMotherboard() { try diff --git a/SynToolkit/ViewModels/SpecsPageViewModel.cs b/SynToolkit/ViewModels/SpecsPageViewModel.cs index b940763..9a6883b 100644 --- a/SynToolkit/ViewModels/SpecsPageViewModel.cs +++ b/SynToolkit/ViewModels/SpecsPageViewModel.cs @@ -12,7 +12,27 @@ namespace SynToolkit.ViewModels { - public sealed record GpuSpecDisplay(string Name, string VramText, string DriverVersionText, string IconPath); + public sealed record GpuDetailDisplay(string Label, string Value); + + public sealed class GpuSpecDisplay + { + internal bool AreDetailsLoaded { get; set; } + internal bool AreDetailsLoading { get; set; } + + public GpuSpecDisplay(string name, string vramText, string driverVersionText, string iconPath) + { + Name = name; + VramText = vramText; + DriverVersionText = driverVersionText; + IconPath = iconPath; + } + + public string Name { get; } + public string VramText { get; } + public string DriverVersionText { get; } + public string IconPath { get; } + public ObservableCollection Details { get; } = new(); + } public sealed record MemoryModuleDisplay(string ManufacturerText, string CapacityText); @@ -20,6 +40,9 @@ public sealed record StorageDriveDisplay(string Model, string SizeText, string T public sealed record NetworkAdapterDisplay(string Name, string ManufacturerText, string StatusText, string DetailsText); + public sealed record CpuDetailDisplay(string Label, string Value); + public sealed record MotherboardDetailDisplay(string Label, string Value); + /// /// Drives the Specs tab: a read-only snapshot of CPU, GPU, memory, storage, motherboard, /// and Windows identity via SystemSpecsService. Purely informational — makes no changes. @@ -27,6 +50,11 @@ public sealed record NetworkAdapterDisplay(string Name, string ManufacturerText, public partial class SpecsPageViewModel : ObservableObject { private readonly ISystemInformationService _systemInformationService; + private readonly CpuUsageSampler _cpuUsageSampler = new(); + private uint? _minimumObservedCpuFrequencyMHz; + private uint? _maximumObservedCpuFrequencyMHz; + private bool _areMotherboardDetailsLoaded; + private bool _areMotherboardDetailsLoading; [ObservableProperty] public partial bool IsLoading { get; set; } = true; @@ -43,6 +71,15 @@ public partial class SpecsPageViewModel : ObservableObject [ObservableProperty] public partial string CpuDetailsText { get; set; } = string.Empty; + [ObservableProperty] + public partial string CpuUtilizationText { get; set; } = "Calculating..."; + + [ObservableProperty] + public partial string CpuCurrentFrequencyText { get; set; } = "Detecting..."; + + [ObservableProperty] + public partial string CpuObservedFrequencyText { get; set; } = "Collecting..."; + [ObservableProperty] public partial string MotherboardText { get; set; } = string.Empty; @@ -66,6 +103,8 @@ public string NetworkSummaryText [ObservableProperty] public partial string GraphicsHeaderIcon { get; set; } = GpuDetectionService.DefaultGpuIconPath; + public ObservableCollection CpuDetails { get; } = new(); + public ObservableCollection MotherboardDetails { get; } = new(); public ObservableCollection Gpus { get; } = new(); public ObservableCollection MemoryModules { get; } = new(); public ObservableCollection StorageDrives { get; } = new(); @@ -85,13 +124,12 @@ public async Task LoadAsync() SystemSpecsSnapshot snapshot = await Task.Run(() => SystemSpecsService.GetSnapshot(_systemInformationService)); CpuName = snapshot.Cpu?.Name ?? "Unknown CPU"; - CpuDetailsText = snapshot.Cpu is null - ? string.Empty - : $"{snapshot.Cpu.Cores} cores, {snapshot.Cpu.LogicalProcessors} logical processors, {snapshot.Cpu.MaxClockSpeedMHz / 1000.0:0.00} GHz"; + CpuDetailsText = CreateCpuSummary(snapshot.Cpu) + " · Loading detailed CPU information..."; + DisplayCpuDetails(snapshot.Cpu, null); MotherboardText = snapshot.Motherboard is null ? "Unknown" - : string.Join(" ", new[] { snapshot.Motherboard.Manufacturer, snapshot.Motherboard.Product }.Where(part => !string.IsNullOrWhiteSpace(part))); + : string.Join(" \u00B7 ", new[] { snapshot.Motherboard.Manufacturer, snapshot.Motherboard.Product }.Where(part => !string.IsNullOrWhiteSpace(part))); TotalMemoryText = FormatBytes(snapshot.TotalMemoryBytes); MemoryDescriptionText = TotalMemoryText; @@ -148,8 +186,9 @@ public async Task LoadAsync() StorageDrives.Add(new StorageDriveDisplay(drive.Model, FormatBytes(drive.SizeBytes), typeText)); } - MemoryDescriptionText = $"{TotalMemoryText} · Loading CAS Latency timings..."; + MemoryDescriptionText = $"{TotalMemoryText} · Loading CAS Latency & timings..."; _ = LoadMemoryTimingDetailsAsync(snapshot.MemoryModules); + _ = LoadCpuDetailsAsync(snapshot.Cpu); } catch (Exception exception) { @@ -164,6 +203,238 @@ public async Task LoadAsync() } + public async Task LoadMotherboardDetailsAsync() + { + if (_areMotherboardDetailsLoaded || _areMotherboardDetailsLoading) + { + return; + } + + _areMotherboardDetailsLoading = true; + MotherboardDetails.Clear(); + MotherboardDetails.Add(new MotherboardDetailDisplay("Motherboard details", "Loading...")); + try + { + IReadOnlyList details = await Task.Run(SystemSpecsService.GetMotherboardDetails); + MotherboardDetails.Clear(); + if (details.Count == 0) + { + MotherboardDetails.Add(new MotherboardDetailDisplay( + "Motherboard details", + "Detailed firmware information is unavailable on this system.")); + return; + } + + foreach (MotherboardDetail detail in details) + { + MotherboardDetails.Add(new MotherboardDetailDisplay(detail.Label, detail.Value)); + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] Motherboard details were unavailable."); + MotherboardDetails.Clear(); + MotherboardDetails.Add(new MotherboardDetailDisplay( + "Motherboard details", + "Detailed firmware information is unavailable on this system.")); + } + finally + { + _areMotherboardDetailsLoading = false; + _areMotherboardDetailsLoaded = true; + } + } + public async Task LoadGpuDetailsAsync(GpuSpecDisplay gpu) + { + if (gpu.AreDetailsLoaded || gpu.AreDetailsLoading) + { + return; + } + + gpu.AreDetailsLoading = true; + gpu.Details.Clear(); + gpu.Details.Add(new GpuDetailDisplay("GPU-Z details", "Loading...")); + try + { + GpuZCardDetails? details = await Task.Run(() => GpuZReportService.GetDetailsFor(gpu.Name)); + gpu.Details.Clear(); + if (details is null || details.Details.Count == 0) + { + gpu.Details.Add(new GpuDetailDisplay("GPU-Z details", "Unavailable for this adapter.")); + return; + } + + foreach (GpuZDetail detail in details.Details) + { + gpu.Details.Add(new GpuDetailDisplay(detail.Label, detail.Value)); + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] GPU-Z details were unavailable for {0}.", gpu.Name); + gpu.Details.Clear(); + gpu.Details.Add(new GpuDetailDisplay("GPU-Z details", "Unavailable for this adapter.")); + } + finally + { + gpu.AreDetailsLoading = false; + gpu.AreDetailsLoaded = true; + } + } + public void RefreshCpuLiveMetrics() + { + CpuLiveMetrics metrics = _cpuUsageSampler.Sample(); + if (metrics.UtilizationPercent.HasValue) + { + CpuUtilizationText = $"{metrics.UtilizationPercent.Value}%"; + } + + if (!metrics.AverageFrequencyMHz.HasValue) + { + return; + } + + uint frequencyMHz = metrics.AverageFrequencyMHz.Value; + CpuCurrentFrequencyText = FormatCpuFrequency(frequencyMHz); + _minimumObservedCpuFrequencyMHz = !_minimumObservedCpuFrequencyMHz.HasValue + ? frequencyMHz + : Math.Min(_minimumObservedCpuFrequencyMHz.Value, frequencyMHz); + _maximumObservedCpuFrequencyMHz = !_maximumObservedCpuFrequencyMHz.HasValue + ? frequencyMHz + : Math.Max(_maximumObservedCpuFrequencyMHz.Value, frequencyMHz); + CpuObservedFrequencyText = _minimumObservedCpuFrequencyMHz == _maximumObservedCpuFrequencyMHz + ? FormatCpuFrequency(_minimumObservedCpuFrequencyMHz.Value) + : $"{FormatCpuFrequency(_minimumObservedCpuFrequencyMHz.Value)} - {FormatCpuFrequency(_maximumObservedCpuFrequencyMHz.Value)}"; + } + + private async Task LoadCpuDetailsAsync(CpuSpec? cpu) + { + try + { + CpuZProcessorDetails? details = await Task.Run(CpuZMemoryReportService.GetCurrentProcessorDetails); + if (details is not null) + { + DisplayCpuDetails(cpu, details); + CpuDetailsText = CreateCpuSummary(cpu, details); + return; + } + } + catch (Exception exception) + { + App.logger.Debug(exception, "[Specs] CPU-Z processor report was unavailable."); + } + + CpuDetailsText = CreateCpuSummary(cpu); + } + + private void DisplayCpuDetails(CpuSpec? cpu, CpuZProcessorDetails? details) + { + CpuDetails.Clear(); + if (cpu is null) + { + return; + } + + AddCpuDetail("Cores", cpu.Cores.ToString(CultureInfo.InvariantCulture)); + AddCpuDetail("Threads", cpu.LogicalProcessors.ToString(CultureInfo.InvariantCulture)); + if (details is null) + { + if (cpu.MaxClockSpeedMHz > 0) + { + AddCpuDetail("Reported maximum frequency", FormatCpuFrequency(cpu.MaxClockSpeedMHz)); + } + + AddCpuDetail("CPU details", "Loading from CPU-Z..."); + return; + } + + if (details.MinimumFrequencyMHz.HasValue && details.MaximumFrequencyMHz.HasValue) + { + AddCpuDetail( + "Minimum - maximum frequency", + $"{FormatCpuFrequency(details.MinimumFrequencyMHz.Value)} - {FormatCpuFrequency(details.MaximumFrequencyMHz.Value)}"); + } + else if (details.MaximumFrequencyMHz.HasValue) + { + AddCpuDetail("Maximum frequency", FormatCpuFrequency(details.MaximumFrequencyMHz.Value)); + } + + AddCoreSet(details.PerformanceCores); + AddCoreSet(details.EfficientCores); + AddCpuDetail("L1 data cache", details.L1DataCache); + AddCpuDetail("L1 instruction cache", details.L1InstructionCache); + AddCpuDetail("L2 cache", details.L2Cache); + AddCpuDetail("L3 cache", details.L3Cache); + if (details.HasAmd3dVCache) + { + AddCpuDetail("AMD 3D V-Cache", "Detected"); + } + + AddCpuDetail("Manufacturer", details.Manufacturer); + AddCpuDetail("Codename", details.Codename); + AddCpuDetail("Socket", details.Socket); + AddCpuDetail("Process", details.Technology); + AddCpuDetail("Thermal design power", details.ThermalDesignPower); + AddCpuDetail("Temperature limit", details.TemperatureLimit); + AddCpuDetail("CPUID", details.Cpuid); + AddCpuDetail("Stepping", details.Stepping); + AddCpuDetail("Instruction sets", details.InstructionSets); + } + + private void AddCoreSet(CpuZCoreSet? coreSet) + { + if (coreSet is null) + { + return; + } + + string value = $"{coreSet.Cores} cores, {coreSet.Threads} threads"; + if (coreSet.MaximumFrequencyMHz.HasValue) + { + value += $" · up to {FormatCpuFrequency(coreSet.MaximumFrequencyMHz.Value)}"; + } + + AddCpuDetail(coreSet.Name, value); + } + + private void AddCpuDetail(string label, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + CpuDetails.Add(new CpuDetailDisplay(label, value)); + } + } + + private static string CreateCpuSummary(CpuSpec? cpu, CpuZProcessorDetails? details = null) + { + if (cpu is null) + { + return string.Empty; + } + + List summary = new() + { + $"{cpu.Cores} cores", + $"{cpu.LogicalProcessors} threads" + }; + if (details?.MinimumFrequencyMHz is decimal minimumFrequencyMHz + && details.MaximumFrequencyMHz is decimal maximumFrequencyMHz) + { + summary.Add($"{FormatCpuFrequency(minimumFrequencyMHz)} - {FormatCpuFrequency(maximumFrequencyMHz)}"); + } + else if (cpu.MaxClockSpeedMHz > 0) + { + summary.Add($"up to {FormatCpuFrequency(cpu.MaxClockSpeedMHz)}"); + } + + return string.Join(" · ", summary); + } + + private static string FormatCpuFrequency(decimal megahertz) => megahertz >= 1000m + ? (megahertz / 1000m).ToString("0.##", CultureInfo.InvariantCulture) + " GHz" + : megahertz.ToString("0", CultureInfo.InvariantCulture) + " MHz"; + + private static string FormatCpuFrequency(uint megahertz) => FormatCpuFrequency((decimal)megahertz); private async Task LoadMemoryTimingDetailsAsync(IReadOnlyList modules) { try diff --git a/SynToolkit/Views/SpecsPage.xaml b/SynToolkit/Views/SpecsPage.xaml index 28ee3d3..95a0730 100644 --- a/SynToolkit/Views/SpecsPage.xaml +++ b/SynToolkit/Views/SpecsPage.xaml @@ -58,26 +58,113 @@ - - - + Style="{StaticResource ConfigurationSettingsExpanderTemplate}"> + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + Style="{StaticResource ConfigurationSettingsExpanderTemplate}"> + - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SynToolkit/Views/SpecsPage.xaml.cs b/SynToolkit/Views/SpecsPage.xaml.cs index fb6d8ea..46a84ba 100644 --- a/SynToolkit/Views/SpecsPage.xaml.cs +++ b/SynToolkit/Views/SpecsPage.xaml.cs @@ -1,6 +1,8 @@ #nullable enable +using System; using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using SynToolkit.ViewModels; @@ -9,13 +11,57 @@ namespace SynToolkit.Views public sealed partial class SpecsPage : Page { private readonly SpecsPageViewModel _viewModel; + private readonly DispatcherTimer _cpuMonitoringTimer; + private bool _isCpuDetailsExpanded; public SpecsPage() { InitializeComponent(); _viewModel = App._host.Services.GetRequiredService(); DataContext = _viewModel; + + _cpuMonitoringTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1250) }; + _cpuMonitoringTimer.Tick += CpuMonitoringTimer_Tick; + Loaded += SpecsPage_Loaded; + Unloaded += SpecsPage_Unloaded; + _ = _viewModel.LoadAsync(); } + + private void CpuDetailsExpander_Expanded(object sender, EventArgs args) + { + _isCpuDetailsExpanded = true; + _viewModel.RefreshCpuLiveMetrics(); + _cpuMonitoringTimer.Start(); + } + + private void CpuDetailsExpander_Collapsed(object sender, EventArgs args) + { + _isCpuDetailsExpanded = false; + _cpuMonitoringTimer.Stop(); + } + private void MotherboardDetailsExpander_Expanded(object sender, EventArgs args) + { + _ = _viewModel.LoadMotherboardDetailsAsync(); + } + private void GpuDetailsExpander_Expanding(Expander sender, ExpanderExpandingEventArgs args) + { + if (sender.DataContext is GpuSpecDisplay gpu) + { + _ = _viewModel.LoadGpuDetailsAsync(gpu); + } + } + private void SpecsPage_Loaded(object sender, RoutedEventArgs e) + { + if (_isCpuDetailsExpanded) + { + _viewModel.RefreshCpuLiveMetrics(); + _cpuMonitoringTimer.Start(); + } + } + + private void SpecsPage_Unloaded(object sender, RoutedEventArgs e) => _cpuMonitoringTimer.Stop(); + + private void CpuMonitoringTimer_Tick(object? sender, object e) => _viewModel.RefreshCpuLiveMetrics(); } -} +} \ No newline at end of file From f6babba44cde0efec931c8281c42899b23cf3599 Mon Sep 17 00:00:00 2001 From: Pew <83255316+Geekmaxxer@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:41:12 -0700 Subject: [PATCH 04/10] Reverted gpu specs from GPU-Z for stability --- SynToolkit.SystemInformationTests/Program.cs | 24 --- .../SynToolkit.SystemInformationTests.csproj | 1 - SynToolkit/Services/GpuZReportParser.cs | 167 ------------------ SynToolkit/Services/GpuZReportService.cs | 86 --------- SynToolkit/Services/SystemSpecsService.cs | 1 - SynToolkit/ViewModels/SpecsPageViewModel.cs | 43 ----- SynToolkit/Views/SpecsPage.xaml | 78 +++----- SynToolkit/Views/SpecsPage.xaml.cs | 7 - 8 files changed, 25 insertions(+), 382 deletions(-) delete mode 100644 SynToolkit/Services/GpuZReportParser.cs delete mode 100644 SynToolkit/Services/GpuZReportService.cs diff --git a/SynToolkit.SystemInformationTests/Program.cs b/SynToolkit.SystemInformationTests/Program.cs index 5319aa0..cfb0c21 100644 --- a/SynToolkit.SystemInformationTests/Program.cs +++ b/SynToolkit.SystemInformationTests/Program.cs @@ -63,7 +63,6 @@ private static int Main() Run("CPU-Z memory timings are parsed", CpuZMemoryTimingsAreParsed); Run("CPU-Z processor details are parsed", CpuZProcessorDetailsAreParsed); Run("AMD 3D V-Cache is detected", Amd3dVCacheIsDetected); - Run("GPU-Z card details are parsed", GpuZCardDetailsAreParsed); Console.WriteLine(_failures == 0 ? "All SynToolkit service tests passed." @@ -176,29 +175,6 @@ private static void Amd3dVCacheIsDetected() True(details?.HasAmd3dVCache == true, "An AMD X3D processor must expose its 3D V-Cache designation."); Equal("96 MB", details?.L3Cache, "The AMD L3 cache capacity should remain visible."); } - private static void GpuZCardDetailsAreParsed() - { - const string report = "\n" + - "" + - "AMD Radeon RX 9060 XTNavi 44" + - "AMD/ATIGigabyte4" + - "PCIe x16 5.0 @ x16 4.0GDDR6" + - "Hynix12864" + - "1282048" + - "27803320" + - "Enabled111" + - ""; - - IReadOnlyList cards = GpuZReportParser.Parse(report); - GpuZCardDetails card = cards.Single(); - - Equal("AMD Radeon RX 9060 XT", card.CardName, "GPU-Z card names should be retained for WMI matching."); - True(card.Details.Single(detail => detail.Label == "Bus interface").Value.StartsWith("PCIe x16 5.0 @ x16 4.0", StringComparison.Ordinal), "The current PCIe link should be shown."); - Equal("GDDR6 \u00B7 Hynix \u00B7 128-bit", card.Details.Single(detail => detail.Label == "Memory").Value, "Memory type, vendor, and bus width should be combined."); - True(card.Details.Count <= 12, "GPU-Z fields should stay condensed into a compact set of rows."); - Equal("2048 Shader Units \u00B7 64 ROPs \u00B7 128 TMUs", card.Details.Single(detail => detail.Label == "Compute").Value, "Unified shader counts should be labeled as shader units, not CPU-style cores."); - Equal("OpenCL, Ray Tracing, OpenGL", card.Details.Single(detail => detail.Label == "Features").Value, "Enabled GPU-Z features should be shown without unsupported APIs."); - } private static void OfficialRegistryPath() => Equal( @"SOFTWARE\AME\Playbooks\Applied", diff --git a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj index a794184..47bb3a2 100644 --- a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj +++ b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj @@ -24,7 +24,6 @@ - diff --git a/SynToolkit/Services/GpuZReportParser.cs b/SynToolkit/Services/GpuZReportParser.cs deleted file mode 100644 index e7a6945..0000000 --- a/SynToolkit/Services/GpuZReportParser.cs +++ /dev/null @@ -1,167 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Xml.Linq; - -namespace SynToolkit.Services -{ - internal sealed record GpuZDetail(string Label, string Value); - - internal sealed record GpuZCardDetails(string CardName, IReadOnlyList Details); - - internal static class GpuZReportParser - { - internal static IReadOnlyList Parse(string report) => Parse(XDocument.Parse(report)); - - internal static IReadOnlyList Parse(Stream report) => Parse(XDocument.Load(report)); - - private static IReadOnlyList Parse(XDocument document) => document.Root? - .Elements("card") - .Select(CreateCard) - .Where(card => !string.IsNullOrWhiteSpace(card.CardName)) - .ToList() - ?? []; - - private static GpuZCardDetails CreateCard(XElement card) - { - List details = new(); - Add(details, "GPU", JoinParts( - Value(card, "gpuname"), - Value(card, "vendor"), - Value(card, "subvendor"))); - Add(details, "Silicon", JoinParts( - WithUnit(Value(card, "processsize"), "nm"), - WithUnit(Value(card, "diesize"), "mm\u00B2"), - AddSuffix(FormatMillions(Value(card, "transistors")), " transistors"), - Value(card, "releasedate"))); - Add(details, "Board", JoinParts( - Combine(Value(card, "vendorid"), Value(card, "deviceid"), ":"), - WithPrefix("Revision", Value(card, "gpurevision")), - WithPrefix("UEFI", ToYesNo(Value(card, "biosuefi"))))); - Add(details, "BIOS", Value(card, "biosversion")); - Add(details, "Bus interface", Value(card, "businterface")); - Add(details, "Platform", JoinParts( - WithPrefix("DirectX", Value(card, "directxsupport")), - WithPrefix("Resizable BAR", Value(card, "resizablebar")))); - Add(details, "Memory", JoinParts( - FormatMemory(card), - WithUnit(NonZero(Value(card, "membandwidth")), "GB/s"))); - Add(details, "Compute", JoinParts( - WithSuffix(NonZero(Value(card, "numshadersunified")), " Shader Units"), - WithSuffix(NonZero(Value(card, "numrops")), " ROPs"), - WithSuffix(NonZero(Value(card, "numtmus")), " TMUs"))); - Add(details, "Throughput", JoinParts( - WithUnit(NonZero(Value(card, "fillratepixel")), "GPixel/s"), - WithUnit(NonZero(Value(card, "fillratetexel")), "GTexel/s"))); - Add(details, "Current clocks", JoinParts( - WithPrefix("GPU", WithUnit(NonZero(Value(card, "clockgpu")), "MHz")), - WithPrefix("Memory", WithUnit(NonZero(Value(card, "clockmem")), "MHz")))); - Add(details, "Boost clock", WithUnit(NonZero(Value(card, "clockgpuboost")), "MHz")); - Add(details, "Driver", JoinParts( - Value(card, "driverversion"), - Value(card, "driverdate"), - Value(card, "whql"))); - Add(details, "Features", FormatFeatures(card)); - - return new GpuZCardDetails(Value(card, "cardname") ?? string.Empty, details); - } - - private static string? Value(XElement card, string name) - { - string? value = card.Element(name)?.Value.Trim(); - return string.IsNullOrWhiteSpace(value) ? null : value; - } - - private static void Add(ICollection details, string label, string? value) - { - if (!string.IsNullOrWhiteSpace(value)) - { - details.Add(new GpuZDetail(label, value)); - } - } - - private static string? JoinParts(params string?[] values) - { - string[] populatedValues = values.Where(value => !string.IsNullOrWhiteSpace(value)).Cast().ToArray(); - return populatedValues.Length == 0 ? null : string.Join(" \u00B7 ", populatedValues); - } - - private static string? FormatMemory(XElement card) - { - List parts = new(); - AddPart(parts, Value(card, "memtype")); - AddPart(parts, Value(card, "memvendor")); - string? busWidth = NonZero(Value(card, "membuswidth")); - if (busWidth is not null) - { - parts.Add(busWidth + "-bit"); - } - - return parts.Count == 0 ? null : string.Join(" \u00B7 ", parts); - } - - private static string? FormatFeatures(XElement card) - { - (string ElementName, string DisplayName)[] features = - [ - ("cuda", "CUDA"), - ("opencl", "OpenCL"), - ("dxcompute", "DirectCompute"), - ("physx", "PhysX"), - ("dxr", "Ray Tracing"), - ("directml", "DirectML"), - ("opengl", "OpenGL") - ]; - string[] enabled = features - .Where(feature => Value(card, feature.ElementName) == "1") - .Select(feature => feature.DisplayName) - .ToArray(); - return enabled.Length == 0 ? null : string.Join(", ", enabled); - } - - private static string? FormatMillions(string? value) - { - if (!long.TryParse(value, out long millions) || millions == 0) - { - return null; - } - - return millions >= 1000 - ? (millions / 1000d).ToString("0.##", CultureInfo.InvariantCulture) + " billion" - : millions.ToString(CultureInfo.InvariantCulture) + " million"; - } - - private static string? WithUnit(string? value, string unit) => NonZero(value) is string nonZero ? nonZero + " " + unit : null; - - private static string? WithPrefix(string prefix, string? value) => value is null ? null : prefix + " " + value; - - private static string? WithSuffix(string? value, string suffix) => value is null ? null : value + suffix; - - private static string? AddSuffix(string? value, string suffix) => value is null ? null : value + suffix; - - private static string? ToYesNo(string? value) => value switch - { - "1" => "Yes", - "0" => "No", - _ => null - }; - - private static string? NonZero(string? value) => value is "0" or "0.0" ? null : value; - - private static string? Combine(string? first, string? second, string separator) => first is not null && second is not null - ? first + separator + second - : first ?? second; - - private static void AddPart(ICollection values, string? value) - { - if (!string.IsNullOrWhiteSpace(value)) - { - values.Add(value); - } - } - } -} \ No newline at end of file diff --git a/SynToolkit/Services/GpuZReportService.cs b/SynToolkit/Services/GpuZReportService.cs deleted file mode 100644 index 904e03c..0000000 --- a/SynToolkit/Services/GpuZReportService.cs +++ /dev/null @@ -1,86 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; - -namespace SynToolkit.Services -{ - /// - /// Reads one hidden GPU-Z XML dump on demand. Its result is shared by all GPU expanders, so - /// Specs does not launch GPU-Z until a user explicitly opens a card's details. - /// - internal static class GpuZReportService - { - private const int ReportTimeoutMilliseconds = 45_000; - private static readonly Lazy> CurrentReport = new(ReadCurrentReport); - - internal static GpuZCardDetails? GetDetailsFor(string gpuName) - { - IReadOnlyList cards = CurrentReport.Value; - return cards.FirstOrDefault(card => card.CardName.Equals(gpuName, StringComparison.OrdinalIgnoreCase)) - ?? cards.FirstOrDefault(card => card.CardName.Contains(gpuName, StringComparison.OrdinalIgnoreCase) - || gpuName.Contains(card.CardName, StringComparison.OrdinalIgnoreCase)); - } - - private static IReadOnlyList ReadCurrentReport() - { - string executablePath = Path.Combine(AppContext.BaseDirectory, "assets", "Tools", "GPU-Z.2.70.0.exe"); - if (!File.Exists(executablePath)) - { - return []; - } - - string reportPath = Path.Combine( - Path.GetTempPath(), - "SynToolkit-GpuZ-" + Guid.NewGuid().ToString("N") + ".xml"); - try - { - using Process process = new(); - process.StartInfo = new ProcessStartInfo - { - FileName = executablePath, - WorkingDirectory = Path.GetDirectoryName(executablePath), - UseShellExecute = false, - CreateNoWindow = true - }; - process.StartInfo.ArgumentList.Add("-dump"); - process.StartInfo.ArgumentList.Add(reportPath); - - if (!process.Start() || !process.WaitForExit(ReportTimeoutMilliseconds) || !File.Exists(reportPath)) - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - - return []; - } - - using FileStream reportStream = File.OpenRead(reportPath); - return GpuZReportParser.Parse(reportStream); - } - catch (Exception exception) - { - App.logger.Debug(exception, "[Specs] GPU-Z report was unavailable."); - return []; - } - finally - { - try - { - if (File.Exists(reportPath)) - { - File.Delete(reportPath); - } - } - catch (IOException) - { - // The temporary report is harmless if a third-party scanner still holds it. - } - } - } - } -} \ No newline at end of file diff --git a/SynToolkit/Services/SystemSpecsService.cs b/SynToolkit/Services/SystemSpecsService.cs index 57cb82f..ef0c0b0 100644 --- a/SynToolkit/Services/SystemSpecsService.cs +++ b/SynToolkit/Services/SystemSpecsService.cs @@ -80,7 +80,6 @@ private static IReadOnlyList GetGpus() string name = item["Name"] as string ?? "Unknown GPU"; string pnpDeviceId = item["PNPDeviceID"] as string ?? string.Empty; string? driverVersion = item["DriverVersion"] as string; - ulong? adapterRam = item["AdapterRAM"] is object rawAdapterRam ? Convert.ToUInt64(rawAdapterRam) : null; diff --git a/SynToolkit/ViewModels/SpecsPageViewModel.cs b/SynToolkit/ViewModels/SpecsPageViewModel.cs index 9a6883b..c247949 100644 --- a/SynToolkit/ViewModels/SpecsPageViewModel.cs +++ b/SynToolkit/ViewModels/SpecsPageViewModel.cs @@ -12,13 +12,8 @@ namespace SynToolkit.ViewModels { - public sealed record GpuDetailDisplay(string Label, string Value); - public sealed class GpuSpecDisplay { - internal bool AreDetailsLoaded { get; set; } - internal bool AreDetailsLoading { get; set; } - public GpuSpecDisplay(string name, string vramText, string driverVersionText, string iconPath) { Name = name; @@ -31,7 +26,6 @@ public GpuSpecDisplay(string name, string vramText, string driverVersionText, st public string VramText { get; } public string DriverVersionText { get; } public string IconPath { get; } - public ObservableCollection Details { get; } = new(); } public sealed record MemoryModuleDisplay(string ManufacturerText, string CapacityText); @@ -244,43 +238,6 @@ public async Task LoadMotherboardDetailsAsync() _areMotherboardDetailsLoaded = true; } } - public async Task LoadGpuDetailsAsync(GpuSpecDisplay gpu) - { - if (gpu.AreDetailsLoaded || gpu.AreDetailsLoading) - { - return; - } - - gpu.AreDetailsLoading = true; - gpu.Details.Clear(); - gpu.Details.Add(new GpuDetailDisplay("GPU-Z details", "Loading...")); - try - { - GpuZCardDetails? details = await Task.Run(() => GpuZReportService.GetDetailsFor(gpu.Name)); - gpu.Details.Clear(); - if (details is null || details.Details.Count == 0) - { - gpu.Details.Add(new GpuDetailDisplay("GPU-Z details", "Unavailable for this adapter.")); - return; - } - - foreach (GpuZDetail detail in details.Details) - { - gpu.Details.Add(new GpuDetailDisplay(detail.Label, detail.Value)); - } - } - catch (Exception exception) - { - App.logger.Debug(exception, "[Specs] GPU-Z details were unavailable for {0}.", gpu.Name); - gpu.Details.Clear(); - gpu.Details.Add(new GpuDetailDisplay("GPU-Z details", "Unavailable for this adapter.")); - } - finally - { - gpu.AreDetailsLoading = false; - gpu.AreDetailsLoaded = true; - } - } public void RefreshCpuLiveMetrics() { CpuLiveMetrics metrics = _cpuUsageSampler.Sample(); diff --git a/SynToolkit/Views/SpecsPage.xaml b/SynToolkit/Views/SpecsPage.xaml index 95a0730..24d9186 100644 --- a/SynToolkit/Views/SpecsPage.xaml +++ b/SynToolkit/Views/SpecsPage.xaml @@ -210,65 +210,37 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - Date: Fri, 21 Aug 2026 00:27:14 -0700 Subject: [PATCH 05/10] Update to mobo details under specs tab --- SynToolkit.SystemInformationTests/Program.cs | 21 ++++++ .../SynToolkit.SystemInformationTests.csproj | 1 + .../Services/CpuZMainboardDetailsParser.cs | 71 +++++++++++++++++++ .../Services/CpuZMemoryReportService.cs | 9 ++- SynToolkit/Services/DiscordPresenceService.cs | 2 +- SynToolkit/Services/SystemSpecsService.cs | 35 +++++++-- 6 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 SynToolkit/Services/CpuZMainboardDetailsParser.cs diff --git a/SynToolkit.SystemInformationTests/Program.cs b/SynToolkit.SystemInformationTests/Program.cs index cfb0c21..5fb55cd 100644 --- a/SynToolkit.SystemInformationTests/Program.cs +++ b/SynToolkit.SystemInformationTests/Program.cs @@ -62,6 +62,7 @@ private static int Main() Run("Identified desktop DIMMs stay separate", IdentifiedDesktopDimmsStaySeparate); Run("CPU-Z memory timings are parsed", CpuZMemoryTimingsAreParsed); Run("CPU-Z processor details are parsed", CpuZProcessorDetailsAreParsed); + Run("CPU-Z motherboard details are parsed", CpuZMainboardDetailsAreParsed); Run("AMD 3D V-Cache is detected", Amd3dVCacheIsDetected); Console.WriteLine(_failures == 0 @@ -162,6 +163,26 @@ private static void CpuZProcessorDetailsAreParsed() Equal("6 × 48 KB + 8 × 32 KB", details?.L1DataCache, "Cache associativity should be omitted while preserving the hybrid cache layout."); Equal("100 \u00B0C", details?.TemperatureLimit, "Temperature output must use a valid degree symbol."); } + private static void CpuZMainboardDetailsAreParsed() + { + const string report = "Chipset\r\n" + + "Northbridge\t\tIntel Alder Lake rev. 02\r\n" + + "Southbridge\t\tIntel B660 rev. 11\r\n" + + "Bus Specification\t\tPCI-Express 4.0 (16.0 GT/s)\r\n" + + "Graphic Interface\t\tPCI-Express 5.0\r\n" + + "Mainboard Model\t\tB660M DS3H AX DDR4 (0x00000444 - 0x8461EA80)\r\n" + + "LPCIO Vendor\t\tITE\r\n" + + "LPCIO Model\t\tIT8689\r\n"; + + CpuZMainboardDetails? details = CpuZMainboardDetailsParser.TryParse(report); + + Equal("B660M DS3H AX DDR4", details?.Model, "CPU-Z's board model should omit the internal hardware ID."); + Equal("Intel Alder Lake rev. 02", details?.Northbridge, "CPU-Z's northbridge should be retained."); + Equal("Intel B660 rev. 11", details?.Southbridge, "CPU-Z's southbridge should be retained."); + Equal("PCI-Express 4.0 (16.0 GT/s)", details?.BusSpecification, "CPU-Z's mainboard bus should be retained."); + Equal("ITE", details?.LpcioVendor, "CPU-Z's LPCIO vendor should be retained."); + Equal("IT8689", details?.LpcioModel, "CPU-Z's LPCIO model should be retained."); + } private static void Amd3dVCacheIsDetected() { const string report = "Processors Information\r\n" + diff --git a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj index 47bb3a2..80f991f 100644 --- a/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj +++ b/SynToolkit.SystemInformationTests/SynToolkit.SystemInformationTests.csproj @@ -24,6 +24,7 @@ + diff --git a/SynToolkit/Services/CpuZMainboardDetailsParser.cs b/SynToolkit/Services/CpuZMainboardDetailsParser.cs new file mode 100644 index 0000000..fffec17 --- /dev/null +++ b/SynToolkit/Services/CpuZMainboardDetailsParser.cs @@ -0,0 +1,71 @@ +#nullable enable + +using System; + +namespace SynToolkit.Services +{ + internal sealed record CpuZMainboardDetails( + string? Model, + string? Northbridge, + string? Southbridge, + string? BusSpecification, + string? GraphicsInterface, + string? LpcioVendor, + string? LpcioModel); + + internal static class CpuZMainboardDetailsParser + { + internal static CpuZMainboardDetails? TryParse(string report) + { + if (string.IsNullOrWhiteSpace(report)) + { + return null; + } + + CpuZMainboardDetails details = new( + CleanModel(ReadValue(report, "Mainboard Model")), + ReadValue(report, "Northbridge"), + ReadValue(report, "Southbridge"), + ReadValue(report, "Bus Specification"), + ReadValue(report, "Graphic Interface"), + ReadValue(report, "LPCIO Vendor"), + ReadValue(report, "LPCIO Model")); + + return details.Model is null + && details.Northbridge is null + && details.Southbridge is null + && details.LpcioVendor is null + ? null + : details; + } + + private static string? ReadValue(string report, string label) + { + foreach (string line in report.Split(["\r\n", "\n"], StringSplitOptions.None)) + { + string trimmed = line.TrimStart(); + if (!trimmed.StartsWith(label, StringComparison.OrdinalIgnoreCase) + || (trimmed.Length > label.Length && !char.IsWhiteSpace(trimmed[label.Length]))) + { + continue; + } + + string value = trimmed[label.Length..].Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + return null; + } + + private static string? CleanModel(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + int hardwareIdStart = value.IndexOf(" (0x", StringComparison.OrdinalIgnoreCase); + return (hardwareIdStart >= 0 ? value[..hardwareIdStart] : value).Trim(); + } + } +} \ No newline at end of file diff --git a/SynToolkit/Services/CpuZMemoryReportService.cs b/SynToolkit/Services/CpuZMemoryReportService.cs index 2cdaff7..1ea7151 100644 --- a/SynToolkit/Services/CpuZMemoryReportService.cs +++ b/SynToolkit/Services/CpuZMemoryReportService.cs @@ -6,10 +6,10 @@ namespace SynToolkit.Services { - internal sealed record CpuZHardwareReport(CpuZMemoryTimings? MemoryTimings, CpuZProcessorDetails? ProcessorDetails); + internal sealed record CpuZHardwareReport(CpuZMemoryTimings? MemoryTimings, CpuZProcessorDetails? ProcessorDetails, CpuZMainboardDetails? MainboardDetails); /// - /// Reads one hidden CPU-Z report per app session. Memory and CPU details share that report, + /// Reads one hidden CPU-Z report per app session. Memory, processor, and motherboard details share that report, /// which prevents a second CPU-Z process and keeps the initial Specs snapshot responsive. /// internal static class CpuZMemoryReportService @@ -21,6 +21,8 @@ internal static class CpuZMemoryReportService internal static CpuZProcessorDetails? GetCurrentProcessorDetails() => CurrentReport.Value?.ProcessorDetails; + internal static CpuZMainboardDetails? GetCurrentMainboardDetails() => CurrentReport.Value?.MainboardDetails; + private static CpuZHardwareReport? ReadCurrentReport() { string executablePath = Path.Combine(AppContext.BaseDirectory, "assets", "Tools", "cpuz_x64.exe"); @@ -58,7 +60,8 @@ internal static class CpuZMemoryReportService string report = File.ReadAllText(reportPath); return new CpuZHardwareReport( CpuZMemoryTimingParser.TryParse(report), - CpuZProcessorDetailsParser.TryParse(report)); + CpuZProcessorDetailsParser.TryParse(report), + CpuZMainboardDetailsParser.TryParse(report)); } catch (Exception exception) { diff --git a/SynToolkit/Services/DiscordPresenceService.cs b/SynToolkit/Services/DiscordPresenceService.cs index 5b58e35..f5b60f2 100644 --- a/SynToolkit/Services/DiscordPresenceService.cs +++ b/SynToolkit/Services/DiscordPresenceService.cs @@ -50,7 +50,7 @@ public bool TryStart(string applicationId, string largeImageKey) _presence = new RichPresence { - Details = "Using SynToolkit", + Details = "Using the best Toolkit", State = "Configuring Windows", Timestamps = Timestamps.Now, Assets = assets, diff --git a/SynToolkit/Services/SystemSpecsService.cs b/SynToolkit/Services/SystemSpecsService.cs index ef0c0b0..939c5f7 100644 --- a/SynToolkit/Services/SystemSpecsService.cs +++ b/SynToolkit/Services/SystemSpecsService.cs @@ -423,9 +423,11 @@ private static bool IsUserFacingPhysicalNetworkAdapter(ManagementBaseObject adap public static IReadOnlyList GetMotherboardDetails() { List details = new(); - AddBaseboardDetails(details); + CpuZMainboardDetails? cpuZMainboard = CpuZMemoryReportService.GetCurrentMainboardDetails(); + AddBaseboardDetails(details, cpuZMainboard); + AddCpuZMainboardDetails(details, cpuZMainboard); AddFirmwareDetails(details); - AddProcessorSocketDetails(details); + AddProcessorSocketDetails(details, CpuZMemoryReportService.GetCurrentProcessorDetails()?.Socket); AddMemorySlotDetails(details); AddExpansionSlotDetails(details); AddNvmeDeviceDetails(details); @@ -433,7 +435,7 @@ public static IReadOnlyList GetMotherboardDetails() return details; } - private static void AddBaseboardDetails(ICollection details) + private static void AddBaseboardDetails(ICollection details, CpuZMainboardDetails? cpuZMainboard) { try { @@ -444,7 +446,7 @@ private static void AddBaseboardDetails(ICollection details) using (item) { AddDetail(details, "Board manufacturer", ReadText(item, "Manufacturer")); - AddDetail(details, "Board model", ReadText(item, "Product")); + AddDetail(details, "Board model", cpuZMainboard?.Model ?? ReadText(item, "Product")); AddDetail(details, "Board revision", ReadText(item, "Version")); AddDetail(details, "Board serial number", ReadText(item, "SerialNumber")); AddDetail(details, "Board asset tag", ReadText(item, "Tag")); @@ -458,6 +460,23 @@ private static void AddBaseboardDetails(ICollection details) } } + private static void AddCpuZMainboardDetails(ICollection details, CpuZMainboardDetails? cpuZMainboard) + { + if (cpuZMainboard is null) + { + return; + } + + AddDetail(details, "Northbridge", cpuZMainboard.Northbridge); + AddDetail(details, "Southbridge", cpuZMainboard.Southbridge); + AddDetail(details, "Mainboard bus", cpuZMainboard.BusSpecification); + AddDetail(details, "Graphics interface", cpuZMainboard.GraphicsInterface); + AddDetail( + details, + "LPCIO", + string.Join(" \u00B7 ", new[] { cpuZMainboard.LpcioVendor, cpuZMainboard.LpcioModel } + .Where(value => !string.IsNullOrWhiteSpace(value)))); + } private static void AddFirmwareDetails(ICollection details) { try @@ -499,8 +518,14 @@ private static void AddFirmwareDetails(ICollection details) AddDetail(details, "Firmware mode", GetFirmwareMode()); } - private static void AddProcessorSocketDetails(ICollection details) + private static void AddProcessorSocketDetails(ICollection details, string? preferredSocket) { + if (!string.IsNullOrWhiteSpace(preferredSocket)) + { + AddDetail(details, "CPU socket", preferredSocket); + return; + } + try { using ManagementObjectSearcher searcher = new("SELECT SocketDesignation FROM Win32_Processor"); From b148487507ca9adfefe14ab7b1e060eae7b2db50 Mon Sep 17 00:00:00 2001 From: Pew <83255316+Geekmaxxer@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:39:43 -0700 Subject: [PATCH 06/10] different langs. now show in home through disk cleanup tab --- SynToolkit/MainWindow.xaml.cs | 2 +- SynToolkit/Views/AdjustmentsPage.xaml | 76 +++++----- SynToolkit/Views/AppFetchPage.xaml | 56 +++---- SynToolkit/Views/CleanerPage.xaml | 34 ++--- SynToolkit/Views/GpuPage.xaml | 126 ++++++++-------- SynToolkit/Views/HomePage.xaml | 8 +- SynToolkit/Views/LocalizedText.cs | 78 ++++++++++ SynToolkit/Views/PowerPlansPage.xaml | 97 +++++++------ SynToolkit/Views/SpecsPage.xaml | 24 +-- SynToolkit/lang/en_us.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/es_es.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/fr_fr.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/ja_jp.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/ko_kr.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/pt_br.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/pt_pt.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/ru_ru.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/sv_se.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/vi_vn.json | 202 ++++++++++++++++++++++++++ SynToolkit/lang/zh_cn.json | 202 ++++++++++++++++++++++++++ 20 files changed, 2512 insertions(+), 211 deletions(-) create mode 100644 SynToolkit/Views/LocalizedText.cs diff --git a/SynToolkit/MainWindow.xaml.cs b/SynToolkit/MainWindow.xaml.cs index ce5f7f3..3d99867 100644 --- a/SynToolkit/MainWindow.xaml.cs +++ b/SynToolkit/MainWindow.xaml.cs @@ -195,7 +195,7 @@ public void LoadText() LearnMoreBtn.Content = App.GetValueFromItemList("LearnMore"); // Navigation Items - Home.Content = App.GetValueFromItemList("Home_HeaderText"); + Home.Content = App.GetValueFromItemList("Home"); InstallerText.Text = App.GetValueFromItemList("Installer"); PowerPlansText.Text = App.GetValueFromItemList("PowerPlans"); AdjustmentsText.Text = App.GetValueFromItemList("Adjustments"); diff --git a/SynToolkit/Views/AdjustmentsPage.xaml b/SynToolkit/Views/AdjustmentsPage.xaml index d51a151..223f669 100644 --- a/SynToolkit/Views/AdjustmentsPage.xaml +++ b/SynToolkit/Views/AdjustmentsPage.xaml @@ -119,7 +119,7 @@ + Text="Add wallpaper" local:LocalizedText.TextKey="AdjustmentsPageTextB573A9CEB952" /> @@ -132,11 +132,11 @@ + Text="Customizations" local:LocalizedText.TextKey="AdjustmentsPageText5732BA5E3133" /> + Title="Administrator access required" local:LocalizedText.TitleKey="AdjustmentsPageTitleA526402028D0" /> - + - - - + @@ -196,33 +196,33 @@ - - - - - + -