diff --git a/Directory.Packages.props b/Directory.Packages.props index 7a89abfc..9053ee56 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + @@ -14,6 +15,7 @@ + @@ -23,9 +25,10 @@ - + + - \ No newline at end of file + diff --git a/src/AutoOS.App/Assets/Fluent/Benchmarks.png b/src/AutoOS.App/Assets/Fluent/Benchmarks.png new file mode 100644 index 00000000..48807bc4 Binary files /dev/null and b/src/AutoOS.App/Assets/Fluent/Benchmarks.png differ diff --git a/src/AutoOS.App/Assets/NavViewMenu/BreadcrumbPageMappingsSettings.cs b/src/AutoOS.App/Assets/NavViewMenu/BreadcrumbPageMappingsSettings.cs index 8e563cde..7fcd9585 100644 --- a/src/AutoOS.App/Assets/NavViewMenu/BreadcrumbPageMappingsSettings.cs +++ b/src/AutoOS.App/Assets/NavViewMenu/BreadcrumbPageMappingsSettings.cs @@ -21,6 +21,7 @@ public partial class BreadcrumbPageMappingsSettings { typeof(Views.Settings.UpdatePage), new BreadcrumbPageConfig { PageTitle = null, IsHeaderVisible = false, ClearNavigation = false } }, { typeof(Views.Settings.BrowsersPage), new BreadcrumbPageConfig { PageTitle = null, IsHeaderVisible = false, ClearNavigation = false } }, { typeof(Views.Settings.ApplicationsPage), new BreadcrumbPageConfig { PageTitle = null, IsHeaderVisible = false, ClearNavigation = false } }, + { typeof(Views.Settings.BenchmarksPage), new BreadcrumbPageConfig { PageTitle = null, IsHeaderVisible = false, ClearNavigation = false } }, { typeof(Views.Settings.GamesPage), new BreadcrumbPageConfig { PageTitle = null, IsHeaderVisible = false, ClearNavigation = false } } }; } diff --git a/src/AutoOS.App/Assets/NavViewMenu/NavigationPageMappingsSettings.cs b/src/AutoOS.App/Assets/NavViewMenu/NavigationPageMappingsSettings.cs index 8359d3da..1df660d2 100644 --- a/src/AutoOS.App/Assets/NavViewMenu/NavigationPageMappingsSettings.cs +++ b/src/AutoOS.App/Assets/NavViewMenu/NavigationPageMappingsSettings.cs @@ -19,6 +19,7 @@ public partial class NavigationPageMappingsSettings {"AutoOS.Views.Settings.UpdatePage", typeof(AutoOS.Views.Settings.UpdatePage)}, {"AutoOS.Views.Settings.BrowsersPage", typeof(AutoOS.Views.Settings.BrowsersPage)}, {"AutoOS.Views.Settings.ApplicationsPage", typeof(AutoOS.Views.Settings.ApplicationsPage)}, + {"AutoOS.Views.Settings.BenchmarksPage", typeof(AutoOS.Views.Settings.BenchmarksPage)}, {"AutoOS.Views.Settings.GamesPage", typeof(AutoOS.Views.Settings.GamesPage)} }; } diff --git a/src/AutoOS.App/Assets/NavViewMenu/Settings.json b/src/AutoOS.App/Assets/NavViewMenu/Settings.json index d8a78125..ae6b5214 100644 --- a/src/AutoOS.App/Assets/NavViewMenu/Settings.json +++ b/src/AutoOS.App/Assets/NavViewMenu/Settings.json @@ -114,6 +114,13 @@ "ImagePath": "ms-appx:///Assets/Fluent/Applications.png", "HideItem": true }, + { + "UniqueId": "AutoOS.Views.Settings.BenchmarksPage", + "Title": "Benchmarks", + "Subtitle": "Benchmarks", + "ImagePath": "ms-appx:///Assets/Fluent/Benchmarks.png", + "HideItem": true + }, { "UniqueId": "AutoOS.Views.Settings.GamesPage", "Title": "Games", diff --git a/src/AutoOS.App/AutoOS.App.csproj b/src/AutoOS.App/AutoOS.App.csproj index e56ad51a..3f29e8eb 100644 --- a/src/AutoOS.App/AutoOS.App.csproj +++ b/src/AutoOS.App/AutoOS.App.csproj @@ -1,68 +1,69 @@ - - WinExe - AutoOS - AutoOS - app.manifest - win-$(Platform).pubxml - true - false - true - partial - true - true - 2 - MSIX - Assets\AppIcon.ico - 1.4.6.1 - - false - + + WinExe + AutoOS + AutoOS + app.manifest + win-$(Platform).pubxml + true + false + true + partial + true + true + 2 + MSIX + Assets\AppIcon.ico + 1.4.6.1 + + false + - - - False - False - True - False - True - True - $(DefaultItemExcludes);AppPackages\** - False - True - C:\Users\AutoOS\Desktop\AutoOS\AutoOS_TemporaryKey.pfx - SHA256 - False - False - False - Never - 0 - + + + False + False + True + False + True + True + $(DefaultItemExcludes);AppPackages\** + False + True + C:\Users\AutoOS\Desktop\AutoOS\AutoOS_TemporaryKey.pfx + SHA256 + False + False + False + Never + 0 + - - - PreserveNewest - - - PreserveNewest - - + + + PreserveNewest + + + PreserveNewest + + - - - PreserveNewest - - + + + PreserveNewest + + - - - - - + + + + + + @@ -75,69 +76,72 @@ Tools extension to be activated for this project even if the Windows App SDK Nuget package has not yet been restored. --> - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - PreserveNewest - - + + + PreserveNewest + + - - - runtimes\win-x64\native\leveldb.dll - PreserveNewest - - - runtimes\win-arm64\native\leveldb.dll - PreserveNewest - - - runtimes\win-x86\native\leveldb.dll - PreserveNewest - - + + + runtimes\win-x64\native\leveldb.dll + PreserveNewest + + + runtimes\win-arm64\native\leveldb.dll + PreserveNewest + + + runtimes\win-x86\native\leveldb.dll + PreserveNewest + + - - - true - + + true + - - - + + + diff --git a/src/AutoOS.App/Common/CommandBarExtensions.cs b/src/AutoOS.App/Common/CommandBarExtensions.cs index cdb84ab3..e7cb38db 100644 --- a/src/AutoOS.App/Common/CommandBarExtensions.cs +++ b/src/AutoOS.App/Common/CommandBarExtensions.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using CommunityToolkit.WinUI.Controls; namespace AutoOS.Common; @@ -57,88 +58,111 @@ public static void SetOverflowButtonAlignment(DependencyObject obj, HorizontalAl obj.SetValue(OverflowButtonAlignmentProperty, value); } - private static void OnApplyOverflowIndentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + private static void ForEachCommandBar(DependencyObject dependencyObject, Action action) { - if (d is not CommandBar commandBar) - return; - - bool newValue = (bool)e.NewValue; - - if (newValue) + if (dependencyObject is CommandBar commandBar) { - commandBar.Opening += CommandBar_Opening; - commandBar.Closed += CommandBar_Closed; - - if (commandBar.IsOpen) - { - ApplyOverflowContainerMargins(commandBar); - } - - if (commandBar.IsLoaded) + action(commandBar); + } + else if (dependencyObject is TabbedCommandBar tabbedCommandBar) + { + if (tabbedCommandBar.IsLoaded) { - UpdateCommandAlignment(commandBar, GetCommandAlignment(commandBar)); - UpdateOverflowButtonAlignment(commandBar, GetOverflowButtonAlignment(commandBar)); + foreach (var item in tabbedCommandBar.MenuItems.OfType()) + action(item); } else { RoutedEventHandler loadedHandler = null; loadedHandler = (sender, args) => { - commandBar.Loaded -= loadedHandler; - UpdateCommandAlignment(commandBar, GetCommandAlignment(commandBar)); - UpdateOverflowButtonAlignment(commandBar, GetOverflowButtonAlignment(commandBar)); + tabbedCommandBar.Loaded -= loadedHandler; + foreach (var item in tabbedCommandBar.MenuItems.OfType()) + action(item); }; - commandBar.Loaded += loadedHandler; + tabbedCommandBar.Loaded += loadedHandler; } } - else - { - commandBar.Opening -= CommandBar_Opening; - commandBar.Closed -= CommandBar_Closed; - RestoreOverflowContainerMargins(commandBar); - } } - private static void OnCommandAlignmentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + private static void OnApplyOverflowIndentChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) { - if (d is not CommandBar commandBar) - return; + bool newValue = (bool)args.NewValue; - if (commandBar.IsLoaded) - { - UpdateCommandAlignment(commandBar, (HorizontalAlignment)e.NewValue); - } - else + ForEachCommandBar(dependencyObject, commandBar => { - RoutedEventHandler loadedHandler = null; - loadedHandler = (sender, args) => + if (newValue) { - commandBar.Loaded -= loadedHandler; - UpdateCommandAlignment(commandBar, GetCommandAlignment(commandBar)); - }; - commandBar.Loaded += loadedHandler; - } + commandBar.Opening += CommandBar_Opening; + commandBar.Closed += CommandBar_Closed; + + if (commandBar.IsOpen) + ApplyOverflowContainerMargins(commandBar); + + if (commandBar.IsLoaded) + { + UpdateCommandAlignment(commandBar, GetCommandAlignment(dependencyObject)); + UpdateOverflowButtonAlignment(commandBar, GetOverflowButtonAlignment(dependencyObject)); + } + else + { + RoutedEventHandler loadedHandler = null; + loadedHandler = (sender, eventArgs) => + { + commandBar.Loaded -= loadedHandler; + UpdateCommandAlignment(commandBar, GetCommandAlignment(dependencyObject)); + UpdateOverflowButtonAlignment(commandBar, GetOverflowButtonAlignment(dependencyObject)); + }; + commandBar.Loaded += loadedHandler; + } + } + else + { + commandBar.Opening -= CommandBar_Opening; + commandBar.Closed -= CommandBar_Closed; + RestoreOverflowContainerMargins(commandBar); + } + }); } - private static void OnOverflowButtonAlignmentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + private static void OnCommandAlignmentChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) { - if (d is not CommandBar commandBar) - return; - - if (commandBar.IsLoaded) + var alignment = (HorizontalAlignment)args.NewValue; + ForEachCommandBar(dependencyObject, commandBar => { - UpdateOverflowButtonAlignment(commandBar, (HorizontalAlignment)e.NewValue); - } - else + if (commandBar.IsLoaded) + UpdateCommandAlignment(commandBar, alignment); + else + { + RoutedEventHandler loadedHandler = null; + loadedHandler = (sender, eventArgs) => + { + commandBar.Loaded -= loadedHandler; + UpdateCommandAlignment(commandBar, alignment); + }; + commandBar.Loaded += loadedHandler; + } + }); + } + + private static void OnOverflowButtonAlignmentChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) + { + var alignment = (HorizontalAlignment)args.NewValue; + ForEachCommandBar(dependencyObject, commandBar => { - RoutedEventHandler loadedHandler = null; - loadedHandler = (sender, args) => + if (commandBar.IsLoaded) + UpdateOverflowButtonAlignment(commandBar, alignment); + else { - commandBar.Loaded -= loadedHandler; - UpdateOverflowButtonAlignment(commandBar, GetOverflowButtonAlignment(commandBar)); - }; - commandBar.Loaded += loadedHandler; - } + RoutedEventHandler loadedHandler = null; + loadedHandler = (sender, eventArgs) => + { + commandBar.Loaded -= loadedHandler; + UpdateOverflowButtonAlignment(commandBar, alignment); + }; + commandBar.Loaded += loadedHandler; + } + }); } private static void UpdateCommandAlignment(CommandBar commandBar, HorizontalAlignment alignment) @@ -163,16 +187,17 @@ private static void UpdateOverflowButtonAlignment(CommandBar commandBar, Horizon catch { } } - private static T FindVisualChild(DependencyObject obj, string name) where T : DependencyObject + private static T FindVisualChild(DependencyObject parent, string childName) where T : DependencyObject { - for (int i = 0; i < Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(obj); i++) + int count = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(parent); + for (int index = 0; index < count; index++) { - var child = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(obj, i); - if (child is T t && (child as FrameworkElement)?.Name == name) + var child = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(parent, index); + if (child is T matched && (child as FrameworkElement)?.Name == childName) { - return t; + return matched; } - var childOfChild = FindVisualChild(child, name); + var childOfChild = FindVisualChild(child, childName); if (childOfChild != null) { return childOfChild; @@ -181,20 +206,16 @@ private static T FindVisualChild(DependencyObject obj, string name) where T : return null; } - private static void CommandBar_Opening(object sender, object e) + private static void CommandBar_Opening(object sender, object eventArgs) { if (sender is CommandBar commandBar) - { ApplyOverflowContainerMargins(commandBar); - } } - private static void CommandBar_Closed(object sender, object e) + private static void CommandBar_Closed(object sender, object eventArgs) { if (sender is CommandBar commandBar) - { RestoreOverflowContainerMargins(commandBar); - } } private static void ApplyOverflowContainerMargins(CommandBar commandBar) @@ -203,17 +224,14 @@ private static void ApplyOverflowContainerMargins(CommandBar commandBar) foreach (var container in commandBar.PrimaryCommands.OfType()) { - if (container.IsInOverflow) - { - margins.TryAdd(container, container.Margin); + if (container.IsInOverflow && margins.TryAdd(container, container.Margin)) container.Margin = new Thickness(32, 0, 0, 0); - } } foreach (var container in commandBar.SecondaryCommands.OfType()) { - margins.TryAdd(container, container.Margin); - container.Margin = new Thickness(32, 0, 0, 0); + if (margins.TryAdd(container, container.Margin)) + container.Margin = new Thickness(32, 0, 0, 0); } } @@ -222,9 +240,7 @@ private static void RestoreOverflowContainerMargins(CommandBar commandBar) if (_marginsMap.TryGetValue(commandBar, out var margins)) { foreach (var (container, margin) in margins) - { container.Margin = margin; - } margins.Clear(); } } diff --git a/src/AutoOS.App/Themes/Styles.xaml b/src/AutoOS.App/Themes/Styles.xaml index 6979f113..027685e7 100644 --- a/src/AutoOS.App/Themes/Styles.xaml +++ b/src/AutoOS.App/Themes/Styles.xaml @@ -881,4 +881,68 @@ + diff --git a/src/AutoOS.App/Views/Settings/Benchmarks/BenchmarkDataHelpers.cs b/src/AutoOS.App/Views/Settings/Benchmarks/BenchmarkDataHelpers.cs new file mode 100644 index 00000000..dd206291 --- /dev/null +++ b/src/AutoOS.App/Views/Settings/Benchmarks/BenchmarkDataHelpers.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; + +namespace AutoOS.Views.Settings.Benchmarks; + +internal static class BenchmarkCsv +{ + public static List ParseCsvLine(string line) + { + if (string.IsNullOrEmpty(line)) + return []; + using var reader = new StringReader(line); + using var parser = new CsvParser(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = false, + BadDataFound = null + }); + if (!parser.Read()) + return []; + return [.. parser.Record]; + } +} + +internal static class BenchmarkStatistics +{ + public static Metrics CalculateMetrics(double[] values, bool isFpsMetric) + { + var result = new Metrics(); + if (values.Length == 0) + return result; + + var sorted = values.OrderBy(v => v).ToArray(); + int n = values.Length; + double sum = values.Sum(); + double arithmeticMean = sum / n; + + int c1 = Math.Max(1, (int)Math.Ceiling(n * 0.01)); + int c01 = Math.Max(1, (int)Math.Ceiling(n * 0.001)); + + if (isFpsMetric) + { + result.Low01 = sorted.Take(c01).Average(); + result.Low1 = sorted.Take(c1).Average(); + } + else + { + var desc = sorted.Reverse().ToArray(); + result.Low01 = desc.Take(c01).Average(); + result.Low1 = desc.Take(c1).Average(); + } + + result.AvgArithmetic = arithmeticMean; + result.AvgHarmonic = HarmonicMean(values); + result.Min = sorted[0]; + result.Max = sorted[n - 1]; + + result.P01 = Percentile(sorted, isFpsMetric ? 0.1 : 99.9); + result.P1 = Percentile(sorted, isFpsMetric ? 1 : 99); + result.P5 = Percentile(sorted, isFpsMetric ? 5 : 95); + result.P50Median = Percentile(sorted, 50); + result.P95 = Percentile(sorted, isFpsMetric ? 95 : 5); + result.P99 = Percentile(sorted, isFpsMetric ? 99 : 1); + + double variance = values.Sum(v => (v - arithmeticMean) * (v - arithmeticMean)) / (n - 1); + result.StdDev = Math.Sqrt(variance); + result.Cv = arithmeticMean != 0 ? result.StdDev / arithmeticMean : 0; + + double sumSqDiff = 0; + for (int i = 1; i < n; i++) + { + double diff = values[i] - values[i - 1]; + sumSqDiff += diff * diff; + } + result.Rmssd = Math.Sqrt(sumSqDiff / (n - 1)); + + double sumRelSq = 0; + int validPairs = 0; + for (int i = 1; i < n; i++) + { + if (values[i - 1] == 0) continue; + double rel = (values[i] - values[i - 1]) / values[i - 1]; + sumRelSq += rel * rel; + validPairs++; + } + result.StepwiseRelSD = validPairs > 0 ? Math.Sqrt(sumRelSq / validPairs) : 0; + + return result; + } + + private static double Percentile(double[] sorted, double percentile) + { + double position = percentile / 100.0 * (sorted.Length - 1); + int lower = (int)Math.Floor(position); + int upper = (int)Math.Ceiling(position); + if (lower == upper) + return sorted[lower]; + double fraction = position - lower; + return sorted[lower] * (1 - fraction) + sorted[upper] * fraction; + } + + private static double HarmonicMean(double[] values) + { + double reciprocalSum = 0; + int count = 0; + foreach (var v in values) + { + if (v > 0) + { + reciprocalSum += 1.0 / v; + count++; + } + } + return count > 0 ? count / reciprocalSum : 0; + } +} + +public class Metrics +{ + public double Low01 { get; set; } + public double Low1 { get; set; } + public double AvgArithmetic { get; set; } + public double AvgHarmonic { get; set; } + public double Min { get; set; } + public double Max { get; set; } + public double P01 { get; set; } + public double P1 { get; set; } + public double P5 { get; set; } + public double P50Median { get; set; } + public double P95 { get; set; } + public double P99 { get; set; } + public double StdDev { get; set; } + public double Cv { get; set; } + public double Rmssd { get; set; } + public double StepwiseRelSD { get; set; } +} diff --git a/src/AutoOS.App/Views/Settings/Benchmarks/BenchmarksViewModel.cs b/src/AutoOS.App/Views/Settings/Benchmarks/BenchmarksViewModel.cs new file mode 100644 index 00000000..43b6cffc --- /dev/null +++ b/src/AutoOS.App/Views/Settings/Benchmarks/BenchmarksViewModel.cs @@ -0,0 +1,786 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.UI.Xaml.Media; + +namespace AutoOS.Views.Settings.Benchmarks; + +public sealed partial class BenchmarksViewModel : ObservableObject +{ + private string _activeTab = "Recordings"; + private string _recordingState = "Empty"; + private string _analysisState = "Empty"; + private string _statisticsState = "Empty"; + private int _selectedRecordingCount; + private bool _selectedRecordingsHaveSameProcess; + private string _processName = string.Empty; + private double _recordingDuration = 30; + private double _recordingDelay = 5; + private bool _isRecording; + private string _analysisChartType = "Bar"; + private readonly HashSet _recordableProcesses = new(StringComparer.OrdinalIgnoreCase); + private ObservableCollection _processSuggestions = []; + private ObservableCollection _recordings = []; + private ObservableCollection _statisticsRows = []; + private ObservableCollection _fpsBarSeries = []; + private ObservableCollection _fpsRenderedBarSeries = []; + private ObservableCollection _fpsBarSeries2 = []; + private ObservableCollection _fpsRenderedBarSeries2 = []; + private ObservableCollection _metricSeries = []; + private ObservableCollection _metricSeries2 = []; + private Windows.UI.Color _fpsColor; + private Windows.UI.Color _fpsColor2; + private Windows.UI.Color _fpsRenderedColor; + private Windows.UI.Color _fpsRenderedColor2; + private SolidColorBrush _fpsChartColor; + private SolidColorBrush _fpsChartColor2; + private string _fpsChartYAxisLabel = "FPS"; + private string _metricChartYAxisLabel = "Milliseconds (ms)"; + private string _fpsChartLabelFormat = "0.#"; + private bool _showFpsChart2; + private bool _showRenderedFps; + private bool _showRenderedFpsChart2; + private bool _showMetricChart2; + private string _fpsChartLabel = string.Empty; + private string _fpsRenderedChartLabel = string.Empty; + private string _fpsChartLabel2 = string.Empty; + private string _fpsRenderedChartLabel2 = string.Empty; + private string _metricChartLabel = string.Empty; + private string _metricChartLabel2 = string.Empty; + private string _recordingAHeader = "Recording A"; + private string _recordingBHeader = "Recording B"; + + public string ActiveTab + { + get => _activeTab; + set => SetProperty(ref _activeTab, value); + } + + public string RecordingState + { + get => _recordingState; + private set => SetProperty(ref _recordingState, value); + } + + public string AnalysisState + { + get => _analysisState; + private set => SetProperty(ref _analysisState, value); + } + + public string StatisticsState + { + get => _statisticsState; + private set => SetProperty(ref _statisticsState, value); + } + + public ObservableCollection Recordings + { + get => _recordings; + private set => SetProperty(ref _recordings, value); + } + + public ObservableCollection StatisticsRows + { + get => _statisticsRows; + set => SetProperty(ref _statisticsRows, value); + } + + public ObservableCollection ProcessSuggestions + { + get => _processSuggestions; + private set => SetProperty(ref _processSuggestions, value); + } + + public string ProcessName + { + get => _processName; + set + { + if (SetProperty(ref _processName, value)) + { + FilterProcessSuggestions(value); + OnPropertyChanged(nameof(CanRecord)); + } + } + } + + public double RecordingDuration + { + get => _recordingDuration; + set + { + if (SetProperty(ref _recordingDuration, value)) + OnPropertyChanged(nameof(CanRecord)); + } + } + + public double RecordingDelay + { + get => _recordingDelay; + set + { + if (SetProperty(ref _recordingDelay, value)) + OnPropertyChanged(nameof(CanRecord)); + } + } + + public bool IsRecording + { + get => _isRecording; + set + { + if (SetProperty(ref _isRecording, value)) + { + OnPropertyChanged(nameof(CanRecord)); + OnPropertyChanged(nameof(RecordLabel)); + } + } + } + + public event Action MetricToggled; + + private bool _showLow01 = true; + private bool _showLow1 = true; + private bool _showAvgArithmetic = true; + private bool _showAvgHarmonic = true; + private bool _showMin; + private bool _showMax; + private bool _showP01; + private bool _showP1; + private bool _showP5; + private bool _showP50Median = true; + private bool _showP95; + private bool _showP99; + + public bool ShowLow01 + { + get => _showLow01; + set + { + if (SetProperty(ref _showLow01, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowLow1 + { + get => _showLow1; + set + { + if (SetProperty(ref _showLow1, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowAvgArithmetic + { + get => _showAvgArithmetic; + set + { + if (SetProperty(ref _showAvgArithmetic, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowAvgHarmonic + { + get => _showAvgHarmonic; + set + { + if (SetProperty(ref _showAvgHarmonic, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowMin + { + get => _showMin; + set + { + if (SetProperty(ref _showMin, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowMax + { + get => _showMax; + set + { + if (SetProperty(ref _showMax, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowP01 + { + get => _showP01; + set + { + if (SetProperty(ref _showP01, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowP1 + { + get => _showP1; + set + { + if (SetProperty(ref _showP1, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowP5 + { + get => _showP5; + set + { + if (SetProperty(ref _showP5, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowP50Median + { + get => _showP50Median; + set + { + if (SetProperty(ref _showP50Median, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowP95 + { + get => _showP95; + set + { + if (SetProperty(ref _showP95, value)) + MetricToggled?.Invoke(); + } + } + + public bool ShowP99 + { + get => _showP99; + set + { + if (SetProperty(ref _showP99, value)) + MetricToggled?.Invoke(); + } + } + + public bool IsMetricEnabled(string metric) => metric switch + { + "0.1% Low" => ShowLow01, + "1% Low" => ShowLow1, + "Avg (Arithmetic)" => ShowAvgArithmetic, + "Avg (Harmonic)" => ShowAvgHarmonic, + "Min" => ShowMin, + "Max" => ShowMax, + "P0.1" => ShowP01, + "P1" => ShowP1, + "P5" => ShowP5, + "P50 (Median)" => ShowP50Median, + "P95" => ShowP95, + "P99" => ShowP99, + _ => true + }; + + public string AnalysisChartType + { + get => _analysisChartType; + set + { + if (SetProperty(ref _analysisChartType, value)) + { + OnPropertyChanged(nameof(MetricVisibility)); + OnPropertyChanged(nameof(BarMetricsVisibility)); + } + } + } + + public Visibility MetricVisibility => + (AnalysisChartType is "Bar" or "Column") ? Visibility.Collapsed : Visibility.Visible; + + public Visibility BarMetricsVisibility => + (AnalysisChartType is "Bar" or "Column") ? Visibility.Visible : Visibility.Collapsed; + + public bool CanRecord => + IsRecording || + (!string.IsNullOrWhiteSpace(ProcessName) && + _recordableProcesses.Contains(ProcessName.Trim()) && + RecordingDuration >= 3 && + RecordingDelay >= 0); + + public string RecordLabel => IsRecording ? "Recording..." : "Record"; + public List ShortcutKeys { get; } = ["Shift", "F11"]; + public bool IsAggregateEnabled => _selectedRecordingCount > 1 && _selectedRecordingsHaveSameProcess; + public bool IsAnalysisToolbarEnabled => _selectedRecordingCount is > 0 and <= 2; + public bool IsSecondColorPickerEnabled => _selectedRecordingCount == 2; + + public void SetRecordableProcesses(IEnumerable processNames) + { + bool selectedProcessWasRecordable = + !string.IsNullOrWhiteSpace(ProcessName) && + _recordableProcesses.Contains(ProcessName.Trim()); + _recordableProcesses.Clear(); + _recordableProcesses.UnionWith(processNames); + if (selectedProcessWasRecordable && !_recordableProcesses.Contains(ProcessName.Trim())) + { + ProcessName = string.Empty; + return; + } + FilterProcessSuggestions(ProcessName); + OnPropertyChanged(nameof(CanRecord)); + } + + private void FilterProcessSuggestions(string query) + { + ProcessSuggestions = [.. _recordableProcesses + .Where(name => string.IsNullOrWhiteSpace(query) || + name.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase)) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)]; + } + + public ObservableCollection FpsBarSeries + { + get => _fpsBarSeries; + set => SetProperty(ref _fpsBarSeries, value); + } + + public ObservableCollection FpsRenderedBarSeries + { + get => _fpsRenderedBarSeries; + set => SetProperty(ref _fpsRenderedBarSeries, value); + } + + public ObservableCollection FpsBarSeries2 + { + get => _fpsBarSeries2; + set => SetProperty(ref _fpsBarSeries2, value); + } + + public ObservableCollection FpsRenderedBarSeries2 + { + get => _fpsRenderedBarSeries2; + set => SetProperty(ref _fpsRenderedBarSeries2, value); + } + + public ObservableCollection MetricSeries + { + get => _metricSeries; + set => SetProperty(ref _metricSeries, value); + } + + public ObservableCollection MetricSeries2 + { + get => _metricSeries2; + set => SetProperty(ref _metricSeries2, value); + } + + public Windows.UI.Color FpsColor + { + get => _fpsColor; + set + { + if (!SetProperty(ref _fpsColor, value)) + return; + FpsChartColor = new SolidColorBrush(value); + FpsRenderedColor = DevWinUI.ColorHelper.GetInterpolatedColor(0.35, value, Colors.White); + } + } + + public Windows.UI.Color FpsColor2 + { + get => _fpsColor2; + set + { + if (!SetProperty(ref _fpsColor2, value)) + return; + FpsChartColor2 = new SolidColorBrush(value); + FpsRenderedColor2 = DevWinUI.ColorHelper.GetInterpolatedColor(0.35, value, Colors.White); + } + } + + public Windows.UI.Color FpsRenderedColor + { + get => _fpsRenderedColor; + private set => SetProperty(ref _fpsRenderedColor, value); + } + + public SolidColorBrush FpsChartColor + { + get => _fpsChartColor; + private set => SetProperty(ref _fpsChartColor, value); + } + + public Windows.UI.Color FpsRenderedColor2 + { + get => _fpsRenderedColor2; + private set => SetProperty(ref _fpsRenderedColor2, value); + } + + public SolidColorBrush FpsChartColor2 + { + get => _fpsChartColor2; + private set => SetProperty(ref _fpsChartColor2, value); + } + + public string FpsChartYAxisLabel + { + get => _fpsChartYAxisLabel; + set => SetProperty(ref _fpsChartYAxisLabel, value); + } + + public string MetricChartYAxisLabel + { + get => _metricChartYAxisLabel; + set => SetProperty(ref _metricChartYAxisLabel, value); + } + + public string FpsChartLabelFormat + { + get => _fpsChartLabelFormat; + set => SetProperty(ref _fpsChartLabelFormat, value); + } + + public bool ShowFpsChart2 + { + get => _showFpsChart2; + set + { + if (SetProperty(ref _showFpsChart2, value)) + OnPropertyChanged(nameof(ShowFpsChart2Visibility)); + } + } + + public bool ShowRenderedFps + { + get => _showRenderedFps; + set + { + if (SetProperty(ref _showRenderedFps, value)) + OnPropertyChanged(nameof(ShowRenderedFpsVisibility)); + } + } + + public bool ShowRenderedFpsChart2 + { + get => _showRenderedFpsChart2; + set + { + if (SetProperty(ref _showRenderedFpsChart2, value)) + OnPropertyChanged(nameof(ShowRenderedFpsChart2Visibility)); + } + } + + public Visibility ShowRenderedFpsVisibility => ShowRenderedFps ? Visibility.Visible : Visibility.Collapsed; + public Visibility ShowFpsChart2Visibility => ShowFpsChart2 ? Visibility.Visible : Visibility.Collapsed; + public Visibility ShowRenderedFpsChart2Visibility => ShowRenderedFpsChart2 ? Visibility.Visible : Visibility.Collapsed; + public Visibility SecondColorPickerVisibility => _selectedRecordingCount == 2 ? Visibility.Visible : Visibility.Collapsed; + + public bool ShowMetricChart2 + { + get => _showMetricChart2; + set => SetProperty(ref _showMetricChart2, value); + } + + public string FpsChartLabel + { + get => _fpsChartLabel; + set => SetProperty(ref _fpsChartLabel, value); + } + + public string FpsRenderedChartLabel + { + get => _fpsRenderedChartLabel; + set => SetProperty(ref _fpsRenderedChartLabel, value); + } + + public string FpsChartLabel2 + { + get => _fpsChartLabel2; + set => SetProperty(ref _fpsChartLabel2, value); + } + + public string FpsRenderedChartLabel2 + { + get => _fpsRenderedChartLabel2; + set => SetProperty(ref _fpsRenderedChartLabel2, value); + } + + public string MetricChartLabel + { + get => _metricChartLabel; + set => SetProperty(ref _metricChartLabel, value); + } + + public string MetricChartLabel2 + { + get => _metricChartLabel2; + set => SetProperty(ref _metricChartLabel2, value); + } + + public string RecordingAHeader + { + get => _recordingAHeader; + set => SetProperty(ref _recordingAHeader, value); + } + + public string RecordingBHeader + { + get => _recordingBHeader; + set => SetProperty(ref _recordingBHeader, value); + } + + public void SetRecordings(IEnumerable recordings) + { + Recordings = new ObservableCollection(recordings); + RecordingState = Recordings.Count == 0 ? "Empty" : "Content"; + } + + public void SetSelectedRecordings(IReadOnlyCollection recordings) + { + _selectedRecordingCount = recordings.Count; + _selectedRecordingsHaveSameProcess = recordings.Count > 0 && + recordings.Select(recording => recording.Process) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() == 1; + + AnalysisState = recordings.Count switch + { + 0 => "Empty", + > 2 => "Error", + _ => "Content" + }; + StatisticsState = AnalysisState; + OnPropertyChanged(nameof(IsAggregateEnabled)); + OnPropertyChanged(nameof(IsAnalysisToolbarEnabled)); + OnPropertyChanged(nameof(IsSecondColorPickerEnabled)); + OnPropertyChanged(nameof(SecondColorPickerVisibility)); + } + + public void ClearAnalysis() + { + FpsBarSeries = null; + FpsRenderedBarSeries = null; + FpsBarSeries2 = null; + FpsRenderedBarSeries2 = null; + MetricSeries = []; + MetricSeries2 = []; + FpsChartLabel = string.Empty; + FpsRenderedChartLabel = string.Empty; + FpsChartLabel2 = string.Empty; + FpsRenderedChartLabel2 = string.Empty; + MetricChartLabel = string.Empty; + MetricChartLabel2 = string.Empty; + ShowFpsChart2 = false; + ShowRenderedFps = false; + ShowRenderedFpsChart2 = false; + ShowMetricChart2 = false; + } + + public void RefreshChartColors() + { + FpsChartColor = new SolidColorBrush(FpsColor); + FpsRenderedColor = DevWinUI.ColorHelper.GetInterpolatedColor(0.35, FpsColor, Colors.White); + FpsChartColor2 = new SolidColorBrush(FpsColor2); + FpsRenderedColor2 = DevWinUI.ColorHelper.GetInterpolatedColor(0.35, FpsColor2, Colors.White); + } + +} + +[WinRT.GeneratedBindableCustomProperty] +public sealed partial class RecordingItem : ObservableObject +{ + private string _filePath = string.Empty; + private string _fileName = string.Empty; + private string _title = string.Empty; + private string _process = string.Empty; + private string _presentationMode = string.Empty; + private double _durationSeconds; + private DateTimeOffset _date; + private TimeSpan _time; + private double _fileSizeKb; + + public ObservableCollection Children { get; } = []; + + public string FilePath + { + get => _filePath; + set => SetProperty(ref _filePath, value); + } + + public string FileName + { + get => _fileName; + set => SetProperty(ref _fileName, value); + } + + public string Title + { + get => _title; + set => SetProperty(ref _title, value); + } + + public string Process + { + get => _process; + set => SetProperty(ref _process, value); + } + + public string PresentationMode + { + get => _presentationMode; + set => SetProperty(ref _presentationMode, value); + } + + public double DurationSeconds + { + get => _durationSeconds; + set => SetProperty(ref _durationSeconds, value); + } + + public DateTimeOffset Date + { + get => _date; + set => SetProperty(ref _date, value); + } + + public TimeSpan Time + { + get => _time; + set => SetProperty(ref _time, value); + } + + public double FileSizeKb + { + get => _fileSizeKb; + set => SetProperty(ref _fileSizeKb, value); + } +} + +[WinRT.GeneratedBindableCustomProperty] +public sealed partial class ResultRow : ObservableObject +{ + private string _metric = string.Empty; + private string _tooltip = string.Empty; + private string _recordingA = string.Empty; + private string _recordingB = string.Empty; + private ResultComparison _recordingAComparison; + private ResultComparison _recordingBComparison; + + public string Metric + { + get => _metric; + set => SetProperty(ref _metric, value); + } + + public string Tooltip + { + get => _tooltip; + set => SetProperty(ref _tooltip, value); + } + + public string RecordingA + { + get => _recordingA; + set => SetProperty(ref _recordingA, value); + } + + public string RecordingB + { + get => _recordingB; + set => SetProperty(ref _recordingB, value); + } + + public ResultComparison RecordingAComparison + { + get => _recordingAComparison; + set => SetProperty(ref _recordingAComparison, value); + } + + public ResultComparison RecordingBComparison + { + get => _recordingBComparison; + set => SetProperty(ref _recordingBComparison, value); + } + + public ObservableCollection Children { get; } = []; +} + +public enum ResultComparison +{ + None, + Better, + Worse +} + +public sealed partial class ResultCellStyleSelector : StyleSelector +{ + public bool IsRecordingA { get; set; } + public Style SuccessStyle { get; set; } + public Style CriticalStyle { get; set; } + + protected override Style SelectStyleCore(object item, DependencyObject container) + { + if (item is not ResultRow row) + return null; + + var comparison = IsRecordingA ? row.RecordingAComparison : row.RecordingBComparison; + return comparison switch + { + ResultComparison.Better => SuccessStyle, + ResultComparison.Worse => CriticalStyle, + _ => null + }; + } +} + +[WinRT.GeneratedBindableCustomProperty] +public sealed partial class BarPoint : ObservableObject +{ + private string _label = string.Empty; + private double _value; + + public string Label + { + get => _label; + set => SetProperty(ref _label, value); + } + + public double Value + { + get => _value; + set => SetProperty(ref _value, value); + } +} + +[WinRT.GeneratedBindableCustomProperty] +public sealed partial class SeriesPoint : ObservableObject +{ + private int _index; + private double _value; + + public int Index + { + get => _index; + set => SetProperty(ref _index, value); + } + + public double Value + { + get => _value; + set => SetProperty(ref _value, value); + } +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(string[]))] +[System.Text.Json.Serialization.JsonSerializable(typeof(List))] +internal sealed partial class BenchmarksJsonContext : System.Text.Json.Serialization.JsonSerializerContext +{ +} + diff --git a/src/AutoOS.App/Views/Settings/Benchmarks/PresentMonProcessDiscovery.cs b/src/AutoOS.App/Views/Settings/Benchmarks/PresentMonProcessDiscovery.cs new file mode 100644 index 00000000..59dfd403 --- /dev/null +++ b/src/AutoOS.App/Views/Settings/Benchmarks/PresentMonProcessDiscovery.cs @@ -0,0 +1,635 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Threading; +using Microsoft.Diagnostics.Tracing; +using Microsoft.Diagnostics.Tracing.Parsers; +using Microsoft.Diagnostics.Tracing.Session; + +namespace AutoOS.Views.Settings.Benchmarks; + +internal sealed partial class PresentMonProcessDiscovery : IDisposable +{ + private static readonly Guid DxgKrnlProvider = new("802EC45A-1E99-4B83-9920-87C98277BA9D"); + private static readonly Guid DxgiProvider = new("CA11C036-0102-4A2D-A6AD-F03CFED5D3C9"); + private static readonly Guid D3D9Provider = new("783ACA0A-790E-4D7F-8451-AA850511C6B9"); + private static readonly Guid KernelProcessProvider = new("22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716"); + private const ulong PresentHistoryKeyword = 0x08000000; + private const ulong DxgiKeyword = 0x3; + private const ulong RuntimePresentKeyword = 0x2; + private const ulong ProcessKeyword = 0x10; + private const int ProcessStartEventId = 0x0001; + private const int ProcessStopEventId = 0x0002; + private const int ProcessRundownEventId = 0x000F; + private const int PresentHistoryStartEventId = 0x00AB; + private const int PresentHistoryDetailedStartEventId = 0x00D7; + private const int DxgiPresentStartEventId = 0x002A; + private const int DxgiPresentStopEventId = 0x002B; + private const int DxgiPresentMultiplaneOverlayStartEventId = 0x0037; + private const int DxgiPresentMultiplaneOverlayStopEventId = 0x0038; + private const int DxgiSwapChainStartEventId = 0x000A; + private const int D3D9PresentStartEventId = 0x0001; + private const int D3D9PresentStopEventId = 0x0002; + private const uint DxgiPresentTest = 0x1; + private const uint DxgiStatusOccluded = 0x087A0001; + private const uint DxgiStatusNoDesktopAccess = 0x087A0005; + private const uint DxgiStatusModeChangeInProgress = 0x087A0008; + private const uint RedirectedCompositionModel = 7; + private static readonly HashSet GraphicsRuntimeModules = new(StringComparer.OrdinalIgnoreCase) + { + "d3d9.dll", + "d3d10.dll", + "d3d11.dll", + "d3d12.dll", + "dxgi.dll", + "vulkan-1.dll", + "opengl32.dll" + }; + private static readonly HashSet CompositionFrameworkModules = new(StringComparer.OrdinalIgnoreCase) + { + "Microsoft.UI.Xaml.dll", + "Windows.UI.Xaml.dll" + }; + private static readonly HashSet ExcludedProcessNames = new(StringComparer.OrdinalIgnoreCase) + { + "ApplicationFrameHost.exe", + "audiodg.exe", + "AutoOS.exe", + "backgroundTaskHost.exe", + "conhost.exe", + "CrossDeviceResume.exe", + "csrss.exe", + "ctfmon.exe", + "dllhost.exe", + "Discord.exe", + "dwm.exe", + "explorer.exe", + "fontdrvhost.exe", + "LockApp.exe", + "lsass.exe", + "Memory Compression.exe", + "msedgeview.exe", + "msedgewebview2.exe", + "PresentMon.exe", + "Registry.exe", + "RuntimeBroker.exe", + "SearchIndexer.exe", + "SecurityHealthService.exe", + "SecurityHealthSystray.exe", + "services.exe", + "sihost.exe", + "smss.exe", + "spoolsv.exe", + "SearchHost.exe", + "ShellExperienceHost.exe", + "ShellHost.exe", + "StartMenuExperienceHost.exe", + "svchost.exe", + "System.exe", + "SystemIdleProcess.exe", + "SystemSettings.exe", + "taskhostw.exe", + "TextInputHost.exe", + "wininit.exe", + "winlogon.exe", + "Widgets.exe", + "WidgetService.exe", + "WmiPrvSE.exe" + }; + + private readonly Lock _sync = new(); + private readonly Dictionary _runningProcesses = []; + private readonly Dictionary _presentingProcesses = []; + private readonly HashSet _runtimePresents = []; + private readonly HashSet _snapshotCandidates = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _redirectedCompositionProcesses = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _confirmedPresentingProcesses = new(StringComparer.OrdinalIgnoreCase); + private TraceEventSession _session; + private bool _started; + + public event EventHandler ProcessesChanged; + + public void Start() + { + lock (_sync) + { + if (_started) + return; + + _started = true; + } + + try + { + _session = new TraceEventSession($"AutoOS.PresentDiscovery.{Environment.ProcessId}") + { + StopOnDispose = true + }; + + var parser = new RegisteredTraceEventParser(_session.Source); + parser.All += ProcessTraceEvent; + _session.EnableProvider(KernelProcessProvider, TraceEventLevel.Informational, ProcessKeyword); + _session.EnableProvider(DxgKrnlProvider, TraceEventLevel.Informational, PresentHistoryKeyword); + _session.EnableProvider(DxgiProvider, TraceEventLevel.Verbose, DxgiKeyword); + _session.EnableProvider(D3D9Provider, TraceEventLevel.Verbose, RuntimePresentKeyword); + + TraceEventSession session = _session; + var traceThread = new Thread(() => ProcessEvents(session)) + { + IsBackground = true, + Name = "PresentMon process discovery" + }; + traceThread.Start(); + + // PresentMon requests process capture-state so processes that started + // before the trace session are emitted as ProcessRundown events. + _session.CaptureState(KernelProcessProvider, ProcessKeyword); + _session.CaptureState(DxgiProvider, DxgiKeyword); + } + catch + { + _session?.Dispose(); + _session = null; + lock (_sync) + { + _started = false; + } + } + } + + public List GetRecordableProcesses(bool refreshRunningProcesses = false) + { + if (refreshRunningProcesses) + RefreshRunningProcesses(); + + lock (_sync) + { + return [.. _presentingProcesses.Values + .Select(process => process.Name) + .Concat(_snapshotCandidates) + .Where(name => !ExcludedProcessNames.Contains(name)) + .Where(name => !_redirectedCompositionProcesses.Contains(name) || + _confirmedPresentingProcesses.Contains(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase)]; + } + } + + private static void ProcessEvents(TraceEventSession session) + { + try + { + session.Source.Process(); + } + catch + { + // Disposing a live ETW session ends this blocking read by throwing. + } + } + + private void ProcessTraceEvent(TraceEvent traceEvent) + { + if (traceEvent.ProviderGuid == KernelProcessProvider) + { + ProcessKernelProcessEvent(traceEvent); + return; + } + + if (traceEvent.ProviderGuid == DxgKrnlProvider) + { + ProcessDxgKrnlEvent(traceEvent); + return; + } + + if (traceEvent.ProviderGuid == DxgiProvider) + { + ProcessDxgiEvent(traceEvent); + return; + } + + if (traceEvent.ProviderGuid == D3D9Provider) + ProcessD3D9Event(traceEvent); + } + + private void ProcessKernelProcessEvent(TraceEvent processEvent) + { + int eventId = (int)processEvent.ID; + if (!TryReadUInt32(processEvent, "ProcessID", out uint processIdValue) || processIdValue > int.MaxValue) + return; + + int processId = (int)processIdValue; + if (eventId == ProcessStopEventId) + { + string stoppedProcessName = null; + lock (_sync) + { + if (_runningProcesses.TryGetValue(processId, out ProcessIdentity runningProcess)) + stoppedProcessName = runningProcess.Name; + else if (_presentingProcesses.TryGetValue(processId, out ProcessIdentity presentingProcess)) + stoppedProcessName = presentingProcess.Name; + _runningProcesses.Remove(processId); + _presentingProcesses.Remove(processId); + _runtimePresents.RemoveWhere(present => present.ProcessId == processId); + } + if (stoppedProcessName is not null) + { + Process[] matchingProcesses = Process.GetProcessesByName( + Path.GetFileNameWithoutExtension(stoppedProcessName)); + bool processIsStillRunning = false; + foreach (Process process in matchingProcesses) + { + try + { + processIsStillRunning |= !process.HasExited; + } + catch (InvalidOperationException) + { + } + process.Dispose(); + } + if (!processIsStillRunning) + { + lock (_sync) + { + _snapshotCandidates.Remove(stoppedProcessName); + _redirectedCompositionProcesses.Remove(stoppedProcessName); + _confirmedPresentingProcesses.Remove(stoppedProcessName); + } + ProcessesChanged?.Invoke(this, EventArgs.Empty); + } + } + return; + } + + if (eventId != ProcessStartEventId && eventId != ProcessRundownEventId) + return; + + RememberRunningProcess(processId); + ClassifyProcess(processId); + } + + private void ClassifyProcess(int processId) + { + string name; + try + { + using Process process = Process.GetProcessById(processId); + if (process.HasExited || + !TryGetProcessName(process, out name) || + ExcludedProcessNames.Contains(name)) + { + return; + } + } + catch (ArgumentException) + { + return; + } + catch (InvalidOperationException) + { + return; + } + catch (Win32Exception) + { + return; + } + + List matchingProcesses = [.. Process.GetProcessesByName( + Path.GetFileNameWithoutExtension(name))]; + bool isCandidate = IsSnapshotCandidate(matchingProcesses); + foreach (Process process in matchingProcesses) + process.Dispose(); + + bool processListChanged; + lock (_sync) + { + _runningProcesses[processId] = new ProcessIdentity(name); + processListChanged = isCandidate + ? _snapshotCandidates.Add(name) + : _snapshotCandidates.Remove(name); + } + if (processListChanged) + ProcessesChanged?.Invoke(this, EventArgs.Empty); + } + + private void ProcessDxgiEvent(TraceEvent presentEvent) + { + int eventId = (int)presentEvent.ID; + if (eventId == DxgiSwapChainStartEventId) + { + RememberPresentingProcess(presentEvent.ProcessID); + return; + } + + if (eventId == DxgiPresentStartEventId || eventId == DxgiPresentMultiplaneOverlayStartEventId) + { + if (!TryReadUInt32(presentEvent, "Flags", out uint flags) || (flags & DxgiPresentTest) != 0) + return; + + RememberRuntimePresentStart(DxgiProvider, presentEvent); + return; + } + + if (eventId != DxgiPresentStopEventId && eventId != DxgiPresentMultiplaneOverlayStopEventId) + return; + + if (!CompleteRuntimePresent(DxgiProvider, presentEvent, out int processId) || + !TryReadUInt32(presentEvent, "Result", out uint result) || + (result & 0x80000000) != 0 || + result == DxgiStatusOccluded || + result == DxgiStatusNoDesktopAccess || + result == DxgiStatusModeChangeInProgress) + { + return; + } + + RememberPresentingProcess(processId); + } + + private void ProcessD3D9Event(TraceEvent presentEvent) + { + int eventId = (int)presentEvent.ID; + if (eventId == D3D9PresentStartEventId) + { + RememberRuntimePresentStart(D3D9Provider, presentEvent); + return; + } + + if (eventId != D3D9PresentStopEventId || + !CompleteRuntimePresent(D3D9Provider, presentEvent, out int processId) || + !TryReadUInt32(presentEvent, "Result", out uint result) || + (result & 0x80000000) != 0) + { + return; + } + + RememberPresentingProcess(processId); + } + + private void RememberRuntimePresentStart(Guid provider, TraceEvent presentEvent) + { + lock (_sync) + { + _runtimePresents.Add(new RuntimePresent(provider, presentEvent.ProcessID, presentEvent.ThreadID)); + } + } + + private bool CompleteRuntimePresent(Guid provider, TraceEvent presentEvent, out int processId) + { + var present = new RuntimePresent(provider, presentEvent.ProcessID, presentEvent.ThreadID); + processId = present.ProcessId; + lock (_sync) + { + return _runtimePresents.Remove(present); + } + } + + private void ProcessDxgKrnlEvent(TraceEvent presentEvent) + { + int eventId = (int)presentEvent.ID; + if ((eventId != PresentHistoryStartEventId && eventId != PresentHistoryDetailedStartEventId) || + !TryReadUInt32(presentEvent, "Model", out uint model) || + !TryGetProcessIdentity(presentEvent.ProcessID, out ProcessIdentity identity)) + { + return; + } + + if (model == RedirectedCompositionModel) + { + bool processWasExcluded = false; + lock (_sync) + { + if (!_confirmedPresentingProcesses.Contains(identity.Name)) + processWasExcluded = _redirectedCompositionProcesses.Add(identity.Name); + } + if (processWasExcluded) + ProcessesChanged?.Invoke(this, EventArgs.Empty); + return; + } + + bool processWasRestored; + lock (_sync) + { + _confirmedPresentingProcesses.Add(identity.Name); + processWasRestored = _redirectedCompositionProcesses.Remove(identity.Name); + } + + RememberPresentingProcess(presentEvent.ProcessID); + if (processWasRestored) + ProcessesChanged?.Invoke(this, EventArgs.Empty); + } + + private void RefreshRunningProcesses() + { + Dictionary> processGroups = new(StringComparer.OrdinalIgnoreCase); + + foreach (Process process in Process.GetProcesses()) + { + if (!TryGetProcessName(process, out string name) || + ExcludedProcessNames.Contains(name) || + process.Id == Environment.ProcessId) + { + process.Dispose(); + continue; + } + + if (!processGroups.TryGetValue(name, out List processes)) + processGroups[name] = processes = []; + processes.Add(process); + } + + HashSet candidates = new(StringComparer.OrdinalIgnoreCase); + foreach (var (name, processes) in processGroups) + { + if (IsSnapshotCandidate(processes)) + candidates.Add(name); + foreach (Process process in processes) + process.Dispose(); + } + lock (_sync) + { + _snapshotCandidates.Clear(); + _snapshotCandidates.UnionWith(candidates); + } + } + + private static bool IsSnapshotCandidate(List processes) + { + if (!processes.Any(HasVisibleMainWindow)) + return false; + + bool hasGraphicsRuntime = false; + foreach (Process process in processes) + { + try + { + foreach (ProcessModule module in process.Modules) + { + if (CompositionFrameworkModules.Contains(module.ModuleName)) + return false; + hasGraphicsRuntime |= GraphicsRuntimeModules.Contains(module.ModuleName); + } + } + catch (InvalidOperationException) + { + } + catch (Win32Exception) + { + } + catch (NotSupportedException) + { + } + } + return hasGraphicsRuntime; + } + + private static bool TryGetProcessName(Process process, out string name) + { + name = string.Empty; + + try + { + string processName = process.ProcessName; + name = processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? processName + : $"{processName}.exe"; + return true; + } + catch (InvalidOperationException) + { + return false; + } + catch (Win32Exception) + { + return false; + } + } + + private static bool HasVisibleMainWindow(Process process) + { + try + { + return process.MainWindowHandle != IntPtr.Zero; + } + catch (InvalidOperationException) + { + return false; + } + } + + private void RememberRunningProcess(int processId) + { + if (processId <= 4 || processId == Environment.ProcessId || + !TryGetProcessIdentity(processId, out ProcessIdentity identity) || + ExcludedProcessNames.Contains(identity.Name)) + { + return; + } + + lock (_sync) + { + _runningProcesses[processId] = identity; + } + } + + private void RememberPresentingProcess(int processId) + { + if (processId <= 4 || processId == Environment.ProcessId || + !TryGetProcessIdentity(processId, out ProcessIdentity identity) || + ExcludedProcessNames.Contains(identity.Name)) + { + return; + } + + bool isNewProcessName; + lock (_sync) + { + isNewProcessName = !_presentingProcesses.Values.Any(process => + string.Equals(process.Name, identity.Name, StringComparison.OrdinalIgnoreCase)); + _runningProcesses[processId] = identity; + _presentingProcesses[processId] = identity; + } + if (isNewProcessName) + ProcessesChanged?.Invoke(this, EventArgs.Empty); + } + + private static bool TryGetProcessIdentity(int processId, out ProcessIdentity identity) + { + identity = default; + + try + { + using Process process = Process.GetProcessById(processId); + if (process.HasExited) + return false; + + string processName = process.ProcessName; + string name = processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? processName + : $"{processName}.exe"; + identity = new ProcessIdentity(name); + return true; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (Win32Exception) + { + return false; + } + } + + private static bool TryReadUInt32(TraceEvent traceEvent, string payloadName, out uint value) + { + value = 0; + int payloadIndex = Array.IndexOf(traceEvent.PayloadNames, payloadName); + if (payloadIndex < 0) + return false; + + object payload = traceEvent.PayloadValue(payloadIndex); + switch (payload) + { + case uint unsignedValue: + value = unsignedValue; + return true; + case int signedValue when signedValue >= 0: + value = (uint)signedValue; + return true; + case ulong longValue when longValue <= uint.MaxValue: + value = (uint)longValue; + return true; + case long signedLongValue when signedLongValue is >= 0 and <= uint.MaxValue: + value = (uint)signedLongValue; + return true; + default: + return false; + } + } + + public void Dispose() + { + lock (_sync) + { + if (!_started) + return; + + _started = false; + _runningProcesses.Clear(); + _presentingProcesses.Clear(); + _runtimePresents.Clear(); + _snapshotCandidates.Clear(); + _redirectedCompositionProcesses.Clear(); + _confirmedPresentingProcesses.Clear(); + } + + _session?.Dispose(); + _session = null; + } + + private readonly record struct ProcessIdentity(string Name); + private readonly record struct RuntimePresent(Guid Provider, int ProcessId, int ThreadId); +} diff --git a/src/AutoOS.App/Views/Settings/Benchmarks/PresentMonRecorder.cs b/src/AutoOS.App/Views/Settings/Benchmarks/PresentMonRecorder.cs new file mode 100644 index 00000000..6e9d154b --- /dev/null +++ b/src/AutoOS.App/Views/Settings/Benchmarks/PresentMonRecorder.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Globalization; + +namespace AutoOS.Views.Settings.Benchmarks; + +internal enum PresentMonRecordingResult +{ + Saved, + Stopped, + NotSaved +} + +internal sealed class PresentMonRecorder +{ + private Process _process; + private bool _stopRequested; + + public async Task RecordAsync( + string presentMonPath, + string outputDirectory, + string processName, + int durationSeconds, + int delaySeconds) + { + _stopRequested = false; + Directory.CreateDirectory(outputDirectory); + var delayTimer = Stopwatch.StartNew(); + while (delayTimer.Elapsed < TimeSpan.FromSeconds(delaySeconds) && !_stopRequested) + await Task.Delay(100); + + if (_stopRequested) + return PresentMonRecordingResult.Stopped; + + int recordingNumber = 1; + string outputPath; + do + { + outputPath = Path.Combine(outputDirectory, $"Recording-{recordingNumber++}.csv"); + } + while (File.Exists(outputPath)); + var startInfo = new ProcessStartInfo + { + FileName = presentMonPath, + UseShellExecute = true, + Verb = "runas", + WindowStyle = ProcessWindowStyle.Hidden + }; + startInfo.ArgumentList.Add("--process_name"); + startInfo.ArgumentList.Add(processName); + startInfo.ArgumentList.Add("--timed"); + startInfo.ArgumentList.Add(durationSeconds.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add("--terminate_after_timed"); + startInfo.ArgumentList.Add("--date_time"); + startInfo.ArgumentList.Add("--track_gpu_video"); + startInfo.ArgumentList.Add("--track_frame_type"); + startInfo.ArgumentList.Add("--track_hw_measurements"); + startInfo.ArgumentList.Add("--track_app_timing"); + startInfo.ArgumentList.Add("--track_pc_latency"); + startInfo.ArgumentList.Add("--output_file"); + startInfo.ArgumentList.Add(outputPath); + + _process = Process.Start(startInfo) ?? + throw new InvalidOperationException("PresentMon could not be started."); + + try + { + await _process.WaitForExitAsync(); + } + finally + { + _process.Dispose(); + _process = null; + } + + if (_stopRequested) + return PresentMonRecordingResult.Stopped; + + return File.Exists(outputPath) && new FileInfo(outputPath).Length > 0 + ? PresentMonRecordingResult.Saved + : PresentMonRecordingResult.NotSaved; + } + + public void Stop() + { + _stopRequested = true; + if (_process != null && !_process.HasExited) + _process.Kill(entireProcessTree: true); + } +} diff --git a/src/AutoOS.App/Views/Settings/BenchmarksPage.xaml b/src/AutoOS.App/Views/Settings/BenchmarksPage.xaml new file mode 100644 index 00000000..d1a41611 --- /dev/null +++ b/src/AutoOS.App/Views/Settings/BenchmarksPage.xaml @@ -0,0 +1,845 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MsBetweenDisplayChange + MsBetweenPresents + MsGPUBusy + MsUntilDisplayed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AutoOS.App/Views/Settings/BenchmarksPage.xaml.cs b/src/AutoOS.App/Views/Settings/BenchmarksPage.xaml.cs new file mode 100644 index 00000000..d3580e05 --- /dev/null +++ b/src/AutoOS.App/Views/Settings/BenchmarksPage.xaml.cs @@ -0,0 +1,1598 @@ +using System.Globalization; +using AutoOS.Core.Helpers.Picker; +using AutoOS.Views.Settings.Benchmarks; +using Syncfusion.UI.Xaml.Charts; +using Syncfusion.UI.Xaml.DataGrid; +using System.Text.Json; +using Windows.System; +using static AutoOS.Views.Settings.Benchmarks.BenchmarkCsv; +namespace AutoOS.Views.Settings; + +public sealed partial class BenchmarksPage : Page +{ + public BenchmarksViewModel ViewModel { get; } = new(); + + private static readonly string RecordingsDirectory = Path.Combine(PathHelper.GetAppDataFolderPath(), "Benchmarks"); + private static readonly string[] MetricLabels = ["0.1% Low Avg", "1% Low Avg", "Average (Arithmetic)", "Average (Harmonic)", "Minimum", "Maximum", "P0.1", "P1", "P5", "P50 (Median)", "P95", "P99"]; + private const string AggregateDurationColumn = "AutoOSAggregateDurationSeconds"; + private const string AggregateSourcesColumn = "AutoOSAggregateSources"; + private GlobalKeyboardHook _globalKeyboardHook; + private VirtualKeyModifiers _currentModifiers = VirtualKeyModifiers.Shift; + private VirtualKey _currentKey = VirtualKey.F11; + internal PresentMonProcessDiscovery PresentingProcesses { get; } = new(); + private void OnGlobalKeyDown(object sender, KeyboardHookEventArgs e) + { + if (e.Key == _currentKey && + e.IsCtrl == _currentModifiers.HasFlag(VirtualKeyModifiers.Control) && + e.IsShift == _currentModifiers.HasFlag(VirtualKeyModifiers.Shift) && + e.IsAlt == _currentModifiers.HasFlag(VirtualKeyModifiers.Menu) && + e.IsWindows == _currentModifiers.HasFlag(VirtualKeyModifiers.Windows)) + { + DispatcherQueue.TryEnqueue(() => + { + if (ViewModel.CanRecord) + Record_Checked(this, null); + }); + } + } + private readonly PresentMonRecorder _recorder = new(); + private sealed record CachedFile(string Path, DateTime LastWriteUtc, Dictionary HeaderIndex); + private readonly Dictionary _headerCache = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<(string path, DateTime lastWriteUtc, string metric), List> _columnCache = []; + private readonly Dictionary<(string path, DateTime lastWriteUtc, string metric, bool isFps), Metrics> _metricsCache = []; + private readonly Dictionary _analysisPresentationCache = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _cacheLock = new(); + public BenchmarksPage() + { + InitializeComponent(); + PresentingProcesses.ProcessesChanged += PresentingProcesses_ProcessesChanged; + ViewModel.FpsColor = Colors.DodgerBlue; + ViewModel.FpsColor2 = Colors.Orange; + ViewModel.MetricToggled += OnMetricToggled; + LoadRecordings(); + } + + private void OnMetricToggled() + { + if (_lastChartPresentation != null && ViewModel.ActiveTab == "Analysis" && (ViewModel.AnalysisChartType is "Bar" or "Column")) + { + ApplyFpsChartPresentation(_lastChartPresentation); + } + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + PresentingProcesses.Start(); + _globalKeyboardHook = new GlobalKeyboardHook(); + _globalKeyboardHook.KeyDown += OnGlobalKeyDown; + _globalKeyboardHook.Start(); + } + protected override void OnNavigatedFrom(NavigationEventArgs e) + { + base.OnNavigatedFrom(e); + if (_globalKeyboardHook != null) + { + _globalKeyboardHook.KeyDown -= OnGlobalKeyDown; + _globalKeyboardHook.Stop(); + _globalKeyboardHook.Dispose(); + _globalKeyboardHook = null; + } + } + private void HotkeyShortcut_PrimaryButtonClick(object sender, ContentDialogButtonClickEventArgs e) + { + HotkeyShortcut.UpdatePreviewKeys(); + HotkeyShortcut.CloseContentDialog(); + if (HotkeyShortcut.Keys == null) return; + _currentModifiers = VirtualKeyModifiers.None; + _currentKey = VirtualKey.None; + foreach (var item in HotkeyShortcut.Keys) + { + string keyName = item switch + { + KeyVisualInfo kvi => kvi.KeyName ?? string.Empty, + string s => s, + _ => item?.ToString() ?? string.Empty + }; + VirtualKey virtKey = item is KeyVisualInfo k ? k.Key.GetValueOrDefault() : VirtualKey.None; + if (keyName.Contains("Ctrl", StringComparison.OrdinalIgnoreCase)) + _currentModifiers |= VirtualKeyModifiers.Control; + else if (keyName.Contains("Shift", StringComparison.OrdinalIgnoreCase)) + _currentModifiers |= VirtualKeyModifiers.Shift; + else if (keyName.Contains("Alt", StringComparison.OrdinalIgnoreCase)) + _currentModifiers |= VirtualKeyModifiers.Menu; + else if (keyName.Contains("Win", StringComparison.OrdinalIgnoreCase)) + _currentModifiers |= VirtualKeyModifiers.Windows; + else if (virtKey != VirtualKey.None) + _currentKey = virtKey; + else if (Enum.TryParse(keyName, ignoreCase: true, out var parsed) && parsed != VirtualKey.None) + _currentKey = parsed; + } + } + + private void RecordingsTreeGrid_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (e.NewSize.Width > 0) + { + foreach (var col in RecordingsTreeGrid.Columns) + col.Width = double.NaN; + RecordingsTreeGrid.InvalidateMeasure(); + RecordingsTreeGrid.UpdateLayout(); + } + } + + private void StatisticsTreeGrid_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (e.NewSize.Width > 0) + { + foreach (var col in StatisticsTreeGrid.Columns) + col.Width = double.NaN; + StatisticsTreeGrid.InvalidateMeasure(); + StatisticsTreeGrid.UpdateLayout(); + } + } + + + private List GetSelectedRecordings() + { + if (RecordingsTreeGrid is null) + return []; + + List selected = RecordingsTreeGrid.SelectedItems is null + ? [] + : [.. RecordingsTreeGrid.SelectedItems.OfType()]; + if (selected.Count == 0 && RecordingsTreeGrid.SelectedItem is RecordingItem item) + selected.Add(item); + + selected = [.. selected.DistinctBy(recording => recording.FilePath, StringComparer.OrdinalIgnoreCase)]; + HashSet selectedPaths = selected + .Select(recording => recording.FilePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + List normalizedSelection = []; + foreach (RecordingItem recording in selected) + { + bool hasSelectedDescendant = false; + HashSet visited = []; + Stack descendants = new(recording.Children); + while (descendants.TryPop(out RecordingItem descendant) && visited.Add(descendant)) + { + if (selectedPaths.Contains(descendant.FilePath)) + { + hasSelectedDescendant = true; + break; + } + foreach (RecordingItem child in descendant.Children) + descendants.Push(child); + } + if (!hasSelectedDescendant) + normalizedSelection.Add(recording); + } + return normalizedSelection; + } + private void AnalysisChartTypeSelector_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args) + { + string chartType = ReferenceEquals(sender.SelectedItem, BarChartItem) + ? "Bar" + : ReferenceEquals(sender.SelectedItem, ColumnChartItem) + ? "Column" + : ReferenceEquals(sender.SelectedItem, ScatterChartItem) + ? "Scatter" + : "Line"; + if (ViewModel.AnalysisChartType == chartType) + return; + + string oldType = ViewModel.AnalysisChartType; + ViewModel.AnalysisChartType = chartType; + if (ViewModel.ActiveTab != "Analysis") + return; + + if (chartType is "Bar" or "Column") + { + if (_lastChartPresentation != null) + { + DispatcherQueue.TryEnqueue(() => + { + if (_lastChartPresentation != null) + ApplyFpsChartPresentation(_lastChartPresentation); + }); + } + return; + } + + if (ViewModel.MetricSeries.Count == 0) + return; + + List firstSeries = [.. ViewModel.MetricSeries]; + List secondSeries = [.. ViewModel.MetricSeries2]; + ViewModel.MetricSeries = []; + ViewModel.MetricSeries2 = []; + DispatcherQueue.TryEnqueue(() => + { + if (ViewModel.AnalysisChartType != chartType) + return; + ViewModel.MetricSeries = [.. firstSeries]; + ViewModel.MetricSeries2 = [.. secondSeries]; + }); + } + // ── Play / Record ──────────────────────────────────────────────────────── + private async void AddRecording_Click(object sender, RoutedEventArgs e) + { + var picker = new FilePicker(App.MainWindow) + { + ShowAllFilesOption = false, + Title = "Add benchmark recordings" + }; + picker.FileTypeChoices.Add("PresentMon recordings", ["*.csv"]); + var files = await picker.PickMultipleFilesAsync(); + if (files.Count == 0) + return; + try + { + Directory.CreateDirectory(RecordingsDirectory); + foreach (var file in files) + { + string destination = Path.Combine(RecordingsDirectory, file.Name); + File.Copy(file.Path, destination, overwrite: false); + } + LoadRecordings(); + } + catch (Exception ex) + { + await new ContentDialog + { + Title = "Recording Error", + Content = ex.Message, + CloseButtonText = "OK", + XamlRoot = XamlRoot + }.ShowAsync(); + } + } + private async void Record_Checked(object sender, RoutedEventArgs e) + { + if (ViewModel.IsRecording) + return; + if (!ViewModel.CanRecord) + { + Record.IsChecked = false; + return; + } + ViewModel.IsRecording = true; + Record.IsChecked = true; + int duration = (int)ViewModel.RecordingDuration; + int delay = (int)ViewModel.RecordingDelay; + string processName = ViewModel.ProcessName.Trim(); + string presentMonPath = Path.Combine(RecordingsDirectory, "PresentMon.exe"); + if (!File.Exists(presentMonPath)) + { + await new ContentDialog + { + Title = "Recording Error", + Content = $"PresentMon.exe was not found at {presentMonPath}. Place PresentMon in the benchmarks directory and try again.", + CloseButtonText = "OK", + XamlRoot = XamlRoot + }.ShowAsync(); + ViewModel.IsRecording = false; + Record.IsChecked = false; + return; + } + string errorMessage = string.Empty; + PresentMonRecordingResult recordingResult = PresentMonRecordingResult.Stopped; + try + { + recordingResult = await _recorder.RecordAsync(presentMonPath, RecordingsDirectory, processName, duration, delay); + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + finally + { + ViewModel.IsRecording = false; + Record.IsChecked = false; + LoadRecordings(); + if (!string.IsNullOrWhiteSpace(errorMessage)) + { + await new ContentDialog + { + Title = "Recording Error", + Content = errorMessage, + CloseButtonText = "OK", + XamlRoot = XamlRoot + }.ShowAsync(); + } + } + if (string.IsNullOrWhiteSpace(errorMessage) && recordingResult == PresentMonRecordingResult.NotSaved) + { + await MessageBox.ShowErrorAsync( + App.MainWindow, + "Process either wasn't in foreground or cannot be recorded with PresentMon", + "Recording failed"); + } + } + private void Record_Unchecked(object sender, RoutedEventArgs e) + { + if (!ViewModel.IsRecording) + return; + _recorder.Stop(); + } + // ── Recordings list ────────────────────────────────────────────────────── + private void LoadRecordings() + { + List recordings = []; + Dictionary> aggregateSources = []; + if (!Directory.Exists(RecordingsDirectory)) + { + ViewModel.SetRecordings(recordings); + ViewModel.SetSelectedRecordings([]); + return; + } + List csvFiles = [.. Directory.GetFiles(RecordingsDirectory, "*.csv") + .OrderByDescending(File.GetLastWriteTime)]; + if (csvFiles.Count == 0) + { + ViewModel.SetRecordings(recordings); + } + else + { + foreach (var file in csvFiles) + { + var info = new FileInfo(file); + var (process, presentationMode, durationSeconds, sourceFileNames) = + ReadRecordingMetadata(file, info); + var recording = new RecordingItem + { + FilePath = file, + FileName = info.Name, + Title = Path.GetFileNameWithoutExtension(info.Name), + Process = process, + PresentationMode = presentationMode, + DurationSeconds = durationSeconds, + Date = info.LastWriteTime, + Time = info.LastWriteTime.TimeOfDay, + FileSizeKb = info.Length / 1024.0 + }; + recordings.Add(recording); + if (sourceFileNames.Count > 0) + aggregateSources[recording] = sourceFileNames; + } + + Dictionary recordingsByFileName = recordings.ToDictionary( + recording => recording.FileName, + StringComparer.OrdinalIgnoreCase); + HashSet childRecordings = []; + foreach (var (aggregate, sourceFileNames) in aggregateSources) + { + foreach (string sourceFileName in sourceFileNames.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (recordingsByFileName.TryGetValue(sourceFileName, out RecordingItem source) && + !ReferenceEquals(source, aggregate)) + { + aggregate.Children.Add(source); + childRecordings.Add(source); + } + } + } + ViewModel.SetRecordings(recordings.Where(recording => !childRecordings.Contains(recording))); + } + ViewModel.SetSelectedRecordings(GetSelectedRecordings()); + } + private static ( + string Process, + string PresentationMode, + double DurationSeconds, + List SourceFileNames) ReadRecordingMetadata( + string filePath, + FileInfo info) + { + string process = Path.GetFileNameWithoutExtension(info.Name); + string presentationMode = string.Empty; + double durationSeconds = Math.Max(0, (info.LastWriteTime - info.CreationTime).TotalSeconds); + List sourceFileNames = []; + try + { + using var reader = new StreamReader(filePath); + var headerLine = reader.ReadLine(); + var firstLine = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(headerLine) || string.IsNullOrWhiteSpace(firstLine)) + return (process, presentationMode, durationSeconds, sourceFileNames); + var headers = ParseCsvLine(headerLine); + var firstValues = ParseCsvLine(firstLine); + string lastLine = firstLine; + const int tailBytes = 64 * 1024; + using var tailStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + long tailStart = Math.Max(0, tailStream.Length - tailBytes); + tailStream.Seek(tailStart, SeekOrigin.Begin); + using var tailReader = new StreamReader(tailStream); + if (tailStart > 0) + tailReader.ReadLine(); + while (!tailReader.EndOfStream) + { + var line = tailReader.ReadLine(); + if (!string.IsNullOrWhiteSpace(line)) + lastLine = line; + } + var lastValues = ParseCsvLine(lastLine); + int applicationIndex = headers.FindIndex(h => string.Equals(h, "Application", StringComparison.OrdinalIgnoreCase)); + if (applicationIndex >= 0 && applicationIndex < firstValues.Count && !string.IsNullOrWhiteSpace(firstValues[applicationIndex])) + process = firstValues[applicationIndex]; + int presentModeIndex = headers.FindIndex(header => + string.Equals(header, "PresentMode", StringComparison.OrdinalIgnoreCase)); + if (presentModeIndex >= 0 && + presentModeIndex < firstValues.Count && + !string.IsNullOrWhiteSpace(firstValues[presentModeIndex])) + { + presentationMode = firstValues[presentModeIndex]; + } + bool hasCsvDuration = false; + int aggregateDurationIndex = headers.FindIndex(h => string.Equals(h, AggregateDurationColumn, StringComparison.OrdinalIgnoreCase)); + if (aggregateDurationIndex >= 0 && aggregateDurationIndex < firstValues.Count && + double.TryParse(firstValues[aggregateDurationIndex], NumberStyles.Float, CultureInfo.InvariantCulture, out double aggregateDuration)) + { + durationSeconds = Math.Max(0, aggregateDuration); + hasCsvDuration = true; + } + int aggregateSourcesIndex = headers.FindIndex(h => + string.Equals(h, AggregateSourcesColumn, StringComparison.OrdinalIgnoreCase)); + if (aggregateSourcesIndex >= 0 && + aggregateSourcesIndex < firstValues.Count && + !string.IsNullOrWhiteSpace(firstValues[aggregateSourcesIndex])) + { + byte[] sourceJson = Convert.FromBase64String(firstValues[aggregateSourcesIndex]); + sourceFileNames = [.. JsonSerializer.Deserialize(sourceJson, BenchmarksJsonContext.Default.ListString) ?? []]; + } + int dateTimeIndex = headers.FindIndex(h => string.Equals(h, "TimeInDateTime", StringComparison.OrdinalIgnoreCase)); + if (!hasCsvDuration && dateTimeIndex >= 0 && dateTimeIndex < firstValues.Count && dateTimeIndex < lastValues.Count && + DateTime.TryParse(firstValues[dateTimeIndex], CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var start) && + DateTime.TryParse(lastValues[dateTimeIndex], CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var end)) + { + durationSeconds = Math.Max(0, (end - start).TotalSeconds); + hasCsvDuration = true; + } + int timeSecondsIndex = headers.FindIndex(h => string.Equals(h, "TimeInSeconds", StringComparison.OrdinalIgnoreCase)); + if (!hasCsvDuration && timeSecondsIndex >= 0 && timeSecondsIndex < firstValues.Count && timeSecondsIndex < lastValues.Count && + double.TryParse(firstValues[timeSecondsIndex], NumberStyles.Float, CultureInfo.InvariantCulture, out double firstTimeSeconds) && + double.TryParse(lastValues[timeSecondsIndex], NumberStyles.Float, CultureInfo.InvariantCulture, out double lastTimeSeconds)) + { + durationSeconds = Math.Max(0, lastTimeSeconds - firstTimeSeconds); + } + } + catch + { + } + return (process, presentationMode, durationSeconds, sourceFileNames); + } + private async void RecordingsTreeGrid_SelectionChanged(object sender, Syncfusion.UI.Xaml.Grids.GridSelectionChangedEventArgs e) + { + var items = GetSelectedRecordings(); + ViewModel.SetSelectedRecordings(items); + ViewModel.ClearAnalysis(); + ViewModel.RefreshChartColors(); + ViewModel.StatisticsRows.Clear(); + if (items.Count is 0 or > 2) + return; + if (ViewModel.ActiveTab == "Analysis") + await RenderAnalysisChartsForSelection(items); + else if (ViewModel.ActiveTab == "Statistics") + await RefreshStatisticsTable(); + } + private async void RecordingsTreeGrid_CurrentCellEndEdit(object sender, CurrentCellEndEditEventArgs e) + { + if (!string.Equals(RecordingsTreeGrid.CurrentColumn?.MappingName, nameof(RecordingItem.Title), StringComparison.Ordinal)) + return; + if (RecordingsTreeGrid.CurrentItem is not RecordingItem recording) + return; + string oldPath = recording.FilePath; + string oldTitle = Path.GetFileNameWithoutExtension(oldPath); + string requestedTitle = recording.Title?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(requestedTitle)) + { + recording.Title = oldTitle; + return; + } + if (string.Equals(requestedTitle, oldTitle, StringComparison.Ordinal)) + return; + string safeTitle = string.Join("_", + requestedTitle.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries)); + if (string.IsNullOrWhiteSpace(safeTitle)) + { + recording.Title = oldTitle; + return; + } + string newPath = Path.Combine(Path.GetDirectoryName(oldPath)!, safeTitle + ".csv"); + try + { + File.Move(oldPath, newPath, overwrite: false); + recording.FilePath = newPath; + recording.FileName = Path.GetFileName(newPath); + recording.Title = safeTitle; + DispatcherQueue.TryEnqueue(LoadRecordings); + } + catch (Exception ex) + { + recording.Title = oldTitle; + await new ContentDialog + { + Title = "Recording Error", + Content = $"Rename failed: {ex.Message}", + CloseButtonText = "OK", + XamlRoot = XamlRoot + }.ShowAsync(); + } + } + private async void DeleteRecordingFlyoutItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuFlyoutItem item) + return; + string filePath = string.Empty; + if (item.CommandParameter is GridRecordContextFlyoutInfo { Record: RecordingItem recording }) + filePath = recording.FilePath; + else if (item.DataContext is GridRecordContextFlyoutInfo { Record: RecordingItem dataContextRecording }) + filePath = dataContextRecording.FilePath; + else if (RecordingsTreeGrid.CurrentItem is RecordingItem currentRecording) + filePath = currentRecording.FilePath; + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + return; + var dialog = new ContentDialog + { + Title = "Delete Recording", + Content = $"Confirm that you want to delete {Path.GetFileName(filePath)}.", + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot + }; + var result = await dialog.ShowAsync(); + if (result != ContentDialogResult.Primary) + return; + try + { + File.Delete(filePath); + LoadRecordings(); + } + catch (Exception ex) + { + await new ContentDialog + { + Title = "Recording Error", + Content = $"Delete failed: {ex.Message}", + CloseButtonText = "OK", + XamlRoot = XamlRoot + }.ShowAsync(); + } + } + // ── Aggregate ──────────────────────────────────────────────────────────── + private async void Aggregate_Click(object sender, RoutedEventArgs e) + { + var selected = GetSelectedRecordings(); + + string processName = selected[0].Process; + if (selected.Any(recording => !string.Equals(recording.Process, processName, StringComparison.OrdinalIgnoreCase))) + { + await new ContentDialog + { + Title = "Recording Error", + Content = "Only recordings from the same process can be aggregated.", + CloseButtonText = "OK", + XamlRoot = XamlRoot + }.ShowAsync(); + return; + } + int aggregateNumber = 1; + string outPath; + do + { + outPath = Path.Combine(RecordingsDirectory, $"Aggregate-{aggregateNumber++}.csv"); + } + while (File.Exists(outPath)); + var fileData = new List<(List Rows, List HeaderList)>(); + foreach (var item in selected) + { + var lines = await File.ReadAllLinesAsync(item.FilePath); + if (lines.Length == 0) continue; + var headerList = ParseCsvLine(lines[0]); + List rows = [.. lines.Skip(1).Select(l => ParseCsvLine(l).ToArray())]; + fileData.Add((rows, headerList)); + } + if (fileData.Count < 2) throw new Exception("Not enough data to aggregate."); + List headerCols = [.. fileData[0].HeaderList]; + int applicationIndex = headerCols.FindIndex(header => string.Equals(header, "Application", StringComparison.OrdinalIgnoreCase)); + if (applicationIndex < 0) + { + applicationIndex = headerCols.Count; + headerCols.Add("Application"); + } + int aggregateDurationIndex = headerCols.FindIndex(header => string.Equals(header, AggregateDurationColumn, StringComparison.OrdinalIgnoreCase)); + if (aggregateDurationIndex < 0) + { + aggregateDurationIndex = headerCols.Count; + headerCols.Add(AggregateDurationColumn); + } + int aggregateSourcesIndex = headerCols.FindIndex(header => + string.Equals(header, AggregateSourcesColumn, StringComparison.OrdinalIgnoreCase)); + if (aggregateSourcesIndex < 0) + { + aggregateSourcesIndex = headerCols.Count; + headerCols.Add(AggregateSourcesColumn); + } + string aggregateSources = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes( + selected.Select(recording => recording.FileName) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + BenchmarksJsonContext.Default.StringArray)); + double meanDurationSeconds = selected.Average(recording => recording.DurationSeconds); + int maxRows = fileData.Max(f => f.Rows.Count); + await using (var writer = new StreamWriter(outPath)) + { + await writer.WriteLineAsync(string.Join(",", headerCols)); + for (int r = 0; r < maxRows; r++) + { + var averagedRow = new string[headerCols.Count]; + for (int c = 0; c < headerCols.Count; c++) + { + if (c == applicationIndex) + { + averagedRow[c] = processName; + continue; + } + if (c == aggregateDurationIndex) + { + averagedRow[c] = meanDurationSeconds.ToString(CultureInfo.InvariantCulture); + continue; + } + if (c == aggregateSourcesIndex) + { + averagedRow[c] = r == 0 ? aggregateSources : string.Empty; + continue; + } + double sum = 0; + int count = 0; + foreach (var (rows, _) in fileData) + { + if (r < rows.Count && c < rows[r].Length && + double.TryParse(rows[r][c], NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) + { + sum += value; + count++; + } + } + if (count > 0) + averagedRow[c] = (sum / count).ToString(CultureInfo.InvariantCulture); + else if (r < fileData[0].Rows.Count && c < fileData[0].Rows[r].Length) + averagedRow[c] = fileData[0].Rows[r][c]; + else + averagedRow[c] = string.Empty; + } + await writer.WriteLineAsync(string.Join(",", averagedRow)); + } + } + LoadRecordings(); + } + // ── Analyse ────────────────────────────────────────────────────────────── + private Task RenderAnalysisChartsForSelection(List items) + { + if (items.Count is <= 0 or > 2 || ViewModel.ActiveTab != "Analysis") + return Task.CompletedTask; + return RenderAnalysisCharts(items); + } + private async void MetricComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + var items = GetSelectedRecordings(); + if (items.Count == 0 || ViewModel.ActiveTab != "Analysis") + return; + await RenderMetricChart(items); + } + // ── Data model for analysis results ────────────────────────────────────── + private sealed record AnalysisModel( + List<(string recordingName, List points)> MetricSeries, + List<( + string recordingName, + Dictionary displayedStats, + Dictionary renderedStats)> FpsStatsSeries); + private sealed record ChartPresentation( + List DisplayedFpsBars1, + List RenderedFpsBars1, + List DisplayedFpsBars2, + List RenderedFpsBars2, + bool ShowRenderedFps1, + bool ShowDisplayedFps2, + bool ShowRenderedFps2, + string DisplayedFpsLabel1, + string RenderedFpsLabel1, + string DisplayedFpsLabel2, + string RenderedFpsLabel2, + List MetricPts1, + List MetricPts2, + bool ShowMetric2, + string MetricLabel1, + string MetricLabel2, + string FpsYAxisLabel, + string FpsLabelFormat, + string MetricYAxisLabel); + private async Task RenderAnalysisCharts(List items) + { + if (ViewModel.ActiveTab != "Analysis") + return; + if (items.Count is 0 or > 2) + { + ClearAnalysisCharts(); + return; + } + var presentation = await CreateAnalysisPresentation(items); + if (ViewModel.ActiveTab != "Analysis" || !IsCurrentSelection(items)) + return; + if (presentation == null) + { + ClearAnalysisCharts(); + return; + } + ApplyAnalysisChartPresentation(presentation); + } + private async Task RenderMetricChart(List items) + { + var presentation = await CreateAnalysisPresentation(items); + if (presentation is not null && + ViewModel.ActiveTab == "Analysis" && + IsCurrentSelection(items)) + ApplyMetricChartPresentation(presentation); + } + private bool IsCurrentSelection(List items) + { + var selected = GetSelectedRecordings(); + return selected.Count == items.Count && + selected.All(current => items.Any(original => + string.Equals(original.FilePath, current.FilePath, StringComparison.OrdinalIgnoreCase))); + } + private Task CreateAnalysisPresentation(List items) + { + string metric = Metric1ComboBox.SelectedItem as string ?? string.Empty; + if (string.IsNullOrWhiteSpace(metric)) + return Task.FromResult(null); + return Task.Run(() => BuildAnalysisPresentation( + items, + metric)); + } + private ChartPresentation BuildAnalysisPresentation( + List items, + string metric) + { + List<(RecordingItem item, DateTime lastWriteUtc)> loaded = [.. items + .Select(item => (item, lastWriteUtc: File.Exists(item.FilePath) + ? File.GetLastWriteTimeUtc(item.FilePath) + : DateTime.MinValue)) + .Where(entry => entry.lastWriteUtc != DateTime.MinValue)]; + if (loaded.Count == 0) + return null; + string presentationCacheKey = string.Join( + "|", + loaded.Select(entry => + $"{entry.item.FilePath}\u001f{entry.lastWriteUtc.Ticks}")) + + $"|\u001e{metric}"; + lock (_cacheLock) + { + if (_analysisPresentationCache.TryGetValue(presentationCacheKey, out ChartPresentation cached)) + return cached; + } + if (!loaded.Any(entry => + GetHeaderIndex(entry.item.FilePath, entry.lastWriteUtc, out var h) && + (h.TryGetValue("MsBetweenDisplayChange", out _) || h.TryGetValue("MsBetweenPresents", out _)))) + return null; + List<(string recordingName, List points)> metricSeries = []; + List<( + string recordingName, + Dictionary displayedStats, + Dictionary renderedStats)> fpsStatsSeries = []; + string metricColumn = GetFpsSourceColumn(metric); + for (int recordingIndex = 0; recordingIndex < loaded.Count; recordingIndex++) + { + var (item, lastWriteUtc) = loaded[recordingIndex]; + LoadAnalysisColumns(item.FilePath, lastWriteUtc); + LoadMetricColumn(item.FilePath, lastWriteUtc, metricColumn, out var rawMetricValues); + List points = new(rawMetricValues.Count); + for (int index = 0; index < rawMetricValues.Count; index++) + points.Add(new SeriesPoint { Index = index + 1, Value = rawMetricValues[index] }); + + metricSeries.Add((item.FileName, points)); + List displayedFrameTimes; + if (string.Equals(metricColumn, "MsBetweenDisplayChange", StringComparison.OrdinalIgnoreCase)) + displayedFrameTimes = [.. rawMetricValues]; + else + LoadMetricColumn(item.FilePath, lastWriteUtc, "MsBetweenDisplayChange", out displayedFrameTimes); + + List renderedFrameTimes; + if (string.Equals(metricColumn, "MsBetweenPresents", StringComparison.OrdinalIgnoreCase)) + renderedFrameTimes = [.. rawMetricValues]; + else + LoadMetricColumn(item.FilePath, lastWriteUtc, "MsBetweenPresents", out renderedFrameTimes); + + fpsStatsSeries.Add(( + item.FileName, + StatsToDict(BenchmarkStatistics.CalculateMetrics([.. displayedFrameTimes + .Where(v => v > 0).Select(v => 1000.0 / v)], isFpsMetric: true)), + StatsToDict(BenchmarkStatistics.CalculateMetrics([.. renderedFrameTimes + .Where(v => v > 0).Select(v => 1000.0 / v)], isFpsMetric: true)))); + } + ChartPresentation presentation = BuildChartPresentation( + new AnalysisModel(metricSeries, fpsStatsSeries)); + lock (_cacheLock) + _analysisPresentationCache[presentationCacheKey] = presentation; + return presentation; + } + private static Dictionary StatsToDict(Metrics m) + { + return new(StringComparer.OrdinalIgnoreCase) + { + ["0.1% Low Avg"] = m.Low01, + ["1% Low Avg"] = m.Low1, + ["Avg (Arithmetic)"] = m.AvgArithmetic, + ["Avg (Harmonic)"] = m.AvgHarmonic, + ["Min"] = m.Min, + ["Max"] = m.Max, + ["P0.1"] = m.P01, + ["P1"] = m.P1, + ["P5"] = m.P5, + ["P50 (Median)"] = m.P50Median, + ["P95"] = m.P95, + ["P99"] = m.P99 + }; + } + private static ChartPresentation BuildChartPresentation(AnalysisModel model) + { + string[] order = ["0.1% Low Avg", "1% Low Avg", "Avg (Arithmetic)", "Avg (Harmonic)", "Min", "Max", "P0.1", "P1", "P5", "P50 (Median)", "P95", "P99"]; + List displayedFpsBars1 = []; + List renderedFpsBars1 = []; + List displayedFpsBars2 = []; + List renderedFpsBars2 = []; + bool showRenderedFps1 = false; + bool showDisplayedFps2 = false; + bool showRenderedFps2 = false; + string displayedFpsLabel1 = string.Empty; + string renderedFpsLabel1 = string.Empty; + string displayedFpsLabel2 = string.Empty; + string renderedFpsLabel2 = string.Empty; + if (model.FpsStatsSeries.Count > 0) + { + int seriesIdx = 0; + foreach (var (recordingName, displayedStats, renderedStats) in model.FpsStatsSeries) + { + List displayedTarget = + seriesIdx == 0 ? displayedFpsBars1 : displayedFpsBars2; + List renderedTarget = + seriesIdx == 0 ? renderedFpsBars1 : renderedFpsBars2; + foreach (string percentile in order) + { + if (displayedStats.TryGetValue(percentile, out double displayedValue)) + displayedTarget.Add(new BarPoint { Label = percentile, Value = displayedValue }); + if (renderedStats.TryGetValue(percentile, out double renderedValue)) + renderedTarget.Add(new BarPoint { Label = percentile, Value = renderedValue }); + } + if (seriesIdx == 0) + { + displayedFpsLabel1 = $"{recordingName} · Displayed FPS"; + renderedFpsLabel1 = $"{recordingName} · Rendered FPS"; + showRenderedFps1 = renderedTarget.Count > 0; + } + else + { + displayedFpsLabel2 = $"{recordingName} · Displayed FPS"; + renderedFpsLabel2 = $"{recordingName} · Rendered FPS"; + showDisplayedFps2 = displayedTarget.Count > 0; + showRenderedFps2 = renderedTarget.Count > 0; + } + seriesIdx++; + } + } + List metricPts1 = []; + List metricPts2 = []; + bool showMetric2 = false; + string metricLabel1 = string.Empty; + string metricLabel2 = string.Empty; + if (model.MetricSeries.Count > 0) + { + int seriesIdx = 0; + foreach (var (recordingName, points) in model.MetricSeries) + { + if (seriesIdx == 0) + { + metricPts1 = points; + metricLabel1 = recordingName; + } + else + { + metricPts2 = points; + metricLabel2 = recordingName; + showMetric2 = true; + } + seriesIdx++; + } + } + return new ChartPresentation( + displayedFpsBars1, + renderedFpsBars1, + displayedFpsBars2, + renderedFpsBars2, + showRenderedFps1, + showDisplayedFps2, + showRenderedFps2, + displayedFpsLabel1, + renderedFpsLabel1, + displayedFpsLabel2, + renderedFpsLabel2, + metricPts1, + metricPts2, + showMetric2, + metricLabel1, + metricLabel2, + "FPS", + "0.#", + "Milliseconds (ms)"); + } + + private void ApplyAnalysisChartPresentation(ChartPresentation presentation) + { + if (ViewModel.ActiveTab != "Analysis") + return; + ApplyFpsChartPresentation(presentation); + ApplyMetricChartPresentation(presentation); + } + private ChartPresentation _lastChartPresentation; + + private void ApplyFpsChartPresentation(ChartPresentation presentation) + { + _lastChartPresentation = presentation; + ViewModel.FpsChartYAxisLabel = presentation.FpsYAxisLabel; + ViewModel.FpsChartLabelFormat = presentation.FpsLabelFormat; + ViewModel.FpsChartLabel = presentation.DisplayedFpsLabel1; + ViewModel.FpsRenderedChartLabel = presentation.RenderedFpsLabel1; + ViewModel.FpsChartLabel2 = presentation.DisplayedFpsLabel2; + ViewModel.FpsRenderedChartLabel2 = presentation.RenderedFpsLabel2; + ViewModel.ShowRenderedFps = presentation.ShowRenderedFps1; + ViewModel.ShowFpsChart2 = presentation.ShowDisplayedFps2; + ViewModel.ShowRenderedFpsChart2 = presentation.ShowRenderedFps2; + var series1Data = presentation.DisplayedFpsBars1.Where(b => ViewModel.IsMetricEnabled(b.Label)).ToList(); + var series1RenderedData = presentation.RenderedFpsBars1.Where(b => ViewModel.IsMetricEnabled(b.Label)).ToList(); + var series2Data = presentation.DisplayedFpsBars2.Where(b => ViewModel.IsMetricEnabled(b.Label)).ToList(); + var series2RenderedData = presentation.RenderedFpsBars2.Where(b => ViewModel.IsMetricEnabled(b.Label)).ToList(); + + ViewModel.FpsBarSeries = [.. series1Data]; + ViewModel.FpsRenderedBarSeries = presentation.ShowRenderedFps1 ? [.. series1RenderedData] : null; + ViewModel.FpsBarSeries2 = presentation.ShowDisplayedFps2 ? [.. series2Data] : null; + ViewModel.FpsRenderedBarSeries2 = presentation.ShowRenderedFps2 ? [.. series2RenderedData] : null; + + + // Manage series collection for Bar chart (only exists when AnalysisChartType == "Bar") + if (FpsChart != null) + { + FpsChart.Series.Clear(); + FpsChart.Series.Add(FpsChart1Series); + if (presentation.ShowRenderedFps1) + FpsChart.Series.Add(FpsRenderedChart1Series); + if (presentation.ShowDisplayedFps2) + FpsChart.Series.Add(FpsChart2Series); + if (presentation.ShowRenderedFps2) + FpsChart.Series.Add(FpsRenderedChart2Series); + + FpsChart1Series.ShowDataLabels = false; + FpsChart1Series.ShowDataLabels = true; + FpsRenderedChart1Series.ShowDataLabels = false; + FpsRenderedChart1Series.ShowDataLabels = presentation.ShowRenderedFps1; + FpsChart2Series.ShowDataLabels = false; + FpsChart2Series.ShowDataLabels = presentation.ShowDisplayedFps2; + FpsRenderedChart2Series.ShowDataLabels = false; + FpsRenderedChart2Series.ShowDataLabels = presentation.ShowRenderedFps2; + + FpsChart.IsTransposed = false; + FpsChart.IsTransposed = true; + } + + // Manage series collection for Column chart (only exists when AnalysisChartType == "Column") + if (ColumnFpsChart != null) + { + ColumnFpsChart.Series.Clear(); + ColumnFpsChart.Series.Add(ColumnFpsChart1Series); + if (presentation.ShowRenderedFps1) + ColumnFpsChart.Series.Add(ColumnFpsRenderedChart1Series); + if (presentation.ShowDisplayedFps2) + ColumnFpsChart.Series.Add(ColumnFpsChart2Series); + if (presentation.ShowRenderedFps2) + ColumnFpsChart.Series.Add(ColumnFpsRenderedChart2Series); + + ColumnFpsChart1Series.ShowDataLabels = false; + ColumnFpsChart1Series.ShowDataLabels = true; + ColumnFpsRenderedChart1Series.ShowDataLabels = false; + ColumnFpsRenderedChart1Series.ShowDataLabels = presentation.ShowRenderedFps1; + ColumnFpsChart2Series.ShowDataLabels = false; + ColumnFpsChart2Series.ShowDataLabels = presentation.ShowDisplayedFps2; + ColumnFpsRenderedChart2Series.ShowDataLabels = false; + ColumnFpsRenderedChart2Series.ShowDataLabels = presentation.ShowRenderedFps2; + } + } + private void ApplyMetricChartPresentation(ChartPresentation presentation) + { + ViewModel.MetricChartYAxisLabel = presentation.MetricYAxisLabel; + ViewModel.MetricChartLabel = presentation.MetricLabel1; + ViewModel.MetricChartLabel2 = presentation.MetricLabel2; + ViewModel.ShowMetricChart2 = presentation.ShowMetric2; + ViewModel.MetricSeries = [.. presentation.MetricPts1]; + ViewModel.MetricSeries2 = [.. presentation.MetricPts2]; + } + private static string GetFpsSourceColumn(string metric) => metric switch + { + "Displayed FPS" => "MsBetweenDisplayChange", + "Rendered FPS" => "MsBetweenPresents", + _ => metric + }; + private void ClearAnalysisCharts() + { + _lastChartPresentation = null; + ViewModel.ClearAnalysis(); + } + // ── Statistics tab ────────────────────────────────────────────────────────── + private static void ApplyResultComparisons(IEnumerable rows, bool comparisonEnabled) + { + if (!comparisonEnabled) + return; + foreach (var row in rows) + { + if (!TryParseNumeric(row.RecordingA, out double recordingA) || + !TryParseNumeric(row.RecordingB, out double recordingB) || + recordingA == recordingB) + { + continue; + } + bool higherIsBetter = row.Metric.EndsWith(" FPS", StringComparison.Ordinal); + bool recordingAIsBetter = higherIsBetter ? recordingA > recordingB : recordingA < recordingB; + row.RecordingAComparison = recordingAIsBetter ? ResultComparison.Better : ResultComparison.Worse; + row.RecordingBComparison = recordingAIsBetter ? ResultComparison.Worse : ResultComparison.Better; + } + } + private static bool TryParseNumeric(string value, out double result) + { + if (string.IsNullOrEmpty(value)) + { + result = 0; + return false; + } + var trimmed = value.AsSpan(); + if (trimmed.EndsWith(" FPS".AsSpan(), StringComparison.Ordinal)) + trimmed = trimmed[..^4]; + else if (trimmed.EndsWith(" ms".AsSpan(), StringComparison.Ordinal)) + trimmed = trimmed[..^3]; + else if (trimmed.EndsWith("%".AsSpan(), StringComparison.Ordinal)) + trimmed = trimmed[..^1]; + return double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + } + private static List GroupResultRows(IEnumerable rows) + { + List groups = []; + var groupLookup = new Dictionary(StringComparer.Ordinal); + foreach (var row in rows) + { + var (groupName, childLabel) = GetResultGroup(row.Metric); + if (!groupLookup.TryGetValue(groupName, out var group)) + { + group = new ResultRow + { + Metric = groupName, + Tooltip = GetResultTooltip(groupName, string.Empty) + }; + groupLookup[groupName] = group; + groups.Add(group); + } + group.Children.Add(new ResultRow + { + Metric = childLabel, + Tooltip = GetResultTooltip(groupName, childLabel), + RecordingA = row.RecordingA, + RecordingB = row.RecordingB, + RecordingAComparison = row.RecordingAComparison, + RecordingBComparison = row.RecordingBComparison + }); + } + return groups; + } + private static (string Group, string ChildLabel) GetResultGroup(string label) + { + if (label.StartsWith("Displayed ", StringComparison.Ordinal)) + return ("Displayed FPS", label["Displayed ".Length..]); + if (label.StartsWith("Rendered ", StringComparison.Ordinal)) + return ("Rendered FPS", label["Rendered ".Length..]); + if (label.Equals("Average MsBetweenDisplayChange", StringComparison.Ordinal)) + return ("MsBetweenDisplayChange", "Average"); + if (label.StartsWith("MsBetweenDisplayChange ", StringComparison.Ordinal)) + return ("MsBetweenDisplayChange", label["MsBetweenDisplayChange ".Length..]); + if (label.Equals("Average MsBetweenPresents", StringComparison.Ordinal)) + return ("MsBetweenPresents", "Average"); + if (label.StartsWith("MsBetweenPresents ", StringComparison.Ordinal)) + return ("MsBetweenPresents", label["MsBetweenPresents ".Length..]); + if (label.Equals("Average MsGPUBusy", StringComparison.Ordinal)) + return ("MsGPUBusy", "Average"); + if (label.StartsWith("MsGPUBusy ", StringComparison.Ordinal)) + return ("MsGPUBusy", label["MsGPUBusy ".Length..]); + if (label.Equals("Average MsUntilDisplayed", StringComparison.Ordinal)) + return ("MsUntilDisplayed", "Average"); + if (label.StartsWith("MsUntilDisplayed ", StringComparison.Ordinal)) + return ("MsUntilDisplayed", label["MsUntilDisplayed ".Length..]); + return ("Other", label); + } + private static string GetResultTooltip(string group, string metric) + { + if (string.IsNullOrEmpty(metric)) + { + return group switch + { + "Rendered FPS" => "Measures how fast the game creates frames before they are sent to your screen.", + "Displayed FPS" => "Measures how fast frames actually change on your screen.", + "MsBetweenPresents" => "The time it takes the game engine to push out each new frame.", + "MsBetweenDisplayChange" => "The time it takes for a new image to physically appear on your screen.", + "MsGPUBusy" => "How long the graphics card works on a single frame.", + "MsUntilDisplayed" => "The delay between the game finishing a frame and it appearing on screen.", + _ => "Benchmark statistic." + }; + } + + if (metric.StartsWith("0.1% Low Avg", StringComparison.Ordinal)) + return "Average FPS across the worst-performing 0.1% of frames. Higher values indicate smoother performance."; + if (metric.StartsWith("1% Low Avg", StringComparison.Ordinal)) + return "Average FPS across the worst-performing 1% of frames. Higher values indicate smoother performance"; + if (metric.StartsWith("Average (Arithmetic)", StringComparison.Ordinal)) + { + if (metric.EndsWith(" FPS", StringComparison.Ordinal)) + return "Conventional average FPS. Every sampled frame contributes equally."; + return "Conventional average frametime. Every sampled frame contributes equally."; + } + if (metric.StartsWith("Average (Harmonic)", StringComparison.Ordinal)) + { + if (metric.EndsWith(" FPS", StringComparison.Ordinal)) + return "Frame-duration-weighted average FPS. Long, low-FPS frames have more influence, making lag spikes more visible."; + return "Frame-duration-weighted average frametime. Long, slow frames have more influence, making spikes more visible."; + } + if (metric.StartsWith("Minimum", StringComparison.Ordinal)) + { + if (metric.EndsWith(" FPS", StringComparison.Ordinal)) + return "Lowest sampled FPS value in the recording."; + return "Shortest single frametime in the recording."; + } + if (metric.StartsWith("Maximum", StringComparison.Ordinal)) + { + if (metric.EndsWith(" FPS", StringComparison.Ordinal)) + return "Highest sampled FPS value in the recording."; + return "Longest single frametime in the recording."; + } + if (metric.StartsWith("P0.1", StringComparison.Ordinal)) + return "FPS threshold containing the bottom 0.1% of sampled frames."; + if (metric.StartsWith("P1", StringComparison.Ordinal)) + return "FPS threshold containing the bottom 1% of sampled frames."; + if (metric.StartsWith("P50", StringComparison.Ordinal)) + { + if (metric.EndsWith(" FPS", StringComparison.Ordinal)) + return "FPS threshold containing 50% of the sampled frames."; + return "Frametime threshold below which 50% of all frames fall. Represents typical frame duration."; + } + if (metric.StartsWith("P5", StringComparison.Ordinal)) + return "FPS threshold containing the bottom 5% of sampled frames."; + if (metric.StartsWith("P95", StringComparison.Ordinal)) + { + if (metric.EndsWith(" FPS", StringComparison.Ordinal)) + return "FPS threshold containing 95% of the sampled frames."; + return "Frametime threshold below which 95% of all frames fall. Captures moderate spikes."; + } + if (metric.StartsWith("P99.", StringComparison.Ordinal)) + return "Frametime threshold below which 99.9% of all frames fall. Captures extreme peak delays."; + if (metric.StartsWith("P99", StringComparison.Ordinal)) + { + if (metric.EndsWith(" FPS", StringComparison.Ordinal)) + return "FPS threshold containing 99% of the sampled frames."; + return "Frametime threshold below which 99% of all frames fall. Captures severe hitches."; + } + if (metric.StartsWith("Standard Deviation", StringComparison.Ordinal)) + { + if (group is "Displayed FPS" or "Rendered FPS") + return "Standard deviation: Measures how widely FPS values are spread around the average. Lower values indicate more consistent frame rates."; + return "Standard deviation: Measures how far individual frametimes typically stray from the average frametime. Lower values indicate more consistent performance. Measured in ms."; + } + if (metric.StartsWith("Coefficient of Variation", StringComparison.Ordinal)) + { + if (group is "Displayed FPS" or "Rendered FPS") + return "Coefficient of variation: Standard deviation divided by the arithmetic mean (StDev / Avg). Useful for comparing FPS consistency across different performance levels. Lower values indicate more stable FPS."; + return "Coefficient of variation: Standard deviation divided by the arithmetic mean (StDev / Avg). Useful for comparing stutter severity across different performance levels. Lower values indicate better frametime stability"; + } + if (metric.StartsWith("Root mean square", StringComparison.Ordinal)) + return "Root mean square of successive differences: Measures frame pacing by comparing the timing of adjacent frames. Lower values indicate smoother frame pacing. Measured in ms."; + if (metric.StartsWith("Stepwise-Relative", StringComparison.Ordinal)) + return "Typical percentage change in rendering time from one frame to the next. Lower values indicate lower spike severity."; + return "Benchmark statistic."; + } + private async Task RefreshStatisticsTable() + { + if (ViewModel.ActiveTab != "Statistics") + return; + var selected = GetSelectedRecordings(); + bool showRecordingB = selected.Count == 2; + bool containsRecordingB = StatisticsTreeGrid.Columns.Contains(StatisticsRecordingBColumn); + if (showRecordingB && !containsRecordingB) + StatisticsTreeGrid.Columns.Add(StatisticsRecordingBColumn); + else if (!showRecordingB && containsRecordingB) + StatisticsTreeGrid.Columns.Remove(StatisticsRecordingBColumn); + if (selected.Count == 0) + { + ViewModel.StatisticsRows.Clear(); + ViewModel.RecordingAHeader = "Recording A"; + ViewModel.RecordingBHeader = "Recording B"; + return; + } + if (selected.Count > 2) + { + ViewModel.StatisticsRows.Clear(); + ViewModel.RecordingAHeader = "Recording A"; + ViewModel.RecordingBHeader = "Recording B"; + return; + } + ViewModel.RecordingAHeader = selected.Count >= 1 ? selected[0].Title : "Recording A"; + ViewModel.RecordingBHeader = selected.Count >= 2 ? selected[1].Title : "Recording B"; + var builtRows = await Task.Run(() => + { + List<(RecordingItem item, DateTime lastWriteUtc)> loaded = []; + List resultRows = []; + foreach (var i in selected.Take(2)) + { + var lastWriteUtc = File.Exists(i.FilePath) ? File.GetLastWriteTimeUtc(i.FilePath) : DateTime.MinValue; + if (lastWriteUtc != DateTime.MinValue) + loaded.Add((i, lastWriteUtc)); + } + if (loaded.Count == 0) + return resultRows; + + void AddStatsRows(string prefix, string column, bool isFps) + { + Metrics[] m = new Metrics[loaded.Count]; + for (int i = 0; i < loaded.Count; i++) + { + if (!TryGetMetricsCached(loaded[i].item.FilePath, loaded[i].lastWriteUtc, column, isFps, out var mm)) + return; + m[i] = mm; + } + foreach (var label in MetricLabels) + { + string a = FormatStat(NumericMetric(m[0], label), isFps); + string b = loaded.Count < 2 ? "" : FormatStat(NumericMetric(m[1], label), isFps); + resultRows.Add(new ResultRow { Metric = $"{prefix} {label} FPS", RecordingA = a, RecordingB = b }); + } + string FormatFpsPacing(double value, string format) => + value == 0 ? "—" : value.ToString(format, CultureInfo.InvariantCulture); + resultRows.Add(new ResultRow + { + Metric = $"{prefix} Standard Deviation (STDEV)", + RecordingA = FormatFpsPacing(m[0].StdDev, "0.###"), + RecordingB = loaded.Count < 2 ? "" : FormatFpsPacing(m[1].StdDev, "0.###") + }); + resultRows.Add(new ResultRow + { + Metric = $"{prefix} Coefficient of Variation (CV)", + RecordingA = FormatFpsPacing(m[0].Cv, "0.#####"), + RecordingB = loaded.Count < 2 ? "" : FormatFpsPacing(m[1].Cv, "0.#####") + }); + } + AddStatsRows("Displayed", "MsBetweenDisplayChange", isFps: true); + AddStatsRows("Rendered", "MsBetweenPresents", isFps: true); + + void AddMsStats(string prefix, string column) + { + Metrics[] m = new Metrics[loaded.Count]; + for (int i = 0; i < loaded.Count; i++) + { + if (!TryGetMetricsCached(loaded[i].item.FilePath, loaded[i].lastWriteUtc, column, isFps: false, out var mm)) + return; + m[i] = mm; + } + string fmtMs(double v) => FormatStat(v, isFps: false); + string fmtSd(double v) => v == 0 ? "—" : v.ToString("0.####", CultureInfo.InvariantCulture) + " ms"; + string fmtRel(double v) => v == 0 ? "—" : v.ToString("0.#####", CultureInfo.InvariantCulture); + resultRows.Add(new ResultRow { Metric = $"{prefix} Average (Arithmetic)", RecordingA = fmtMs(m[0].AvgArithmetic), RecordingB = loaded.Count < 2 ? "" : fmtMs(m[1].AvgArithmetic) }); + resultRows.Add(new ResultRow { Metric = $"{prefix} P50 (Median)", RecordingA = fmtMs(m[0].P50Median), RecordingB = loaded.Count < 2 ? "" : fmtMs(m[1].P50Median) }); + resultRows.Add(new ResultRow { Metric = $"{prefix} P95", RecordingA = fmtMs(m[0].P5), RecordingB = loaded.Count < 2 ? "" : fmtMs(m[1].P5) }); + resultRows.Add(new ResultRow { Metric = $"{prefix} P99", RecordingA = fmtMs(m[0].P1), RecordingB = loaded.Count < 2 ? "" : fmtMs(m[1].P1) }); + resultRows.Add(new ResultRow { Metric = $"{prefix} P99.9", RecordingA = fmtMs(m[0].P01), RecordingB = loaded.Count < 2 ? "" : fmtMs(m[1].P01) }); + resultRows.Add(new ResultRow { Metric = $"{prefix} Maximum", RecordingA = fmtMs(m[0].Max), RecordingB = loaded.Count < 2 ? "" : fmtMs(m[1].Max) }); + resultRows.Add(new ResultRow { Metric = $"{prefix} Minimum", RecordingA = fmtMs(m[0].Min), RecordingB = loaded.Count < 2 ? "" : fmtMs(m[1].Min) }); + string fmtPct(double v) => v == 0 ? "—" : v.ToString("0.0") + "%"; + string aRmssdPct = m[0].AvgArithmetic != 0 ? fmtPct(m[0].Rmssd / m[0].AvgArithmetic * 100) : "—"; + string bRmssdPct = loaded.Count < 2 ? "" : (m[1].AvgArithmetic != 0 ? fmtPct(m[1].Rmssd / m[1].AvgArithmetic * 100) : "—"); + resultRows.Add(new ResultRow { Metric = $"{prefix} Root mean square of successive differences (RMSSD)", RecordingA = aRmssdPct, RecordingB = bRmssdPct }); + string aSr = fmtRel(m[0].StepwiseRelSD); + string bSr = loaded.Count < 2 ? "" : fmtRel(m[1].StepwiseRelSD); + resultRows.Add(new ResultRow { Metric = $"{prefix} Stepwise-Relative", RecordingA = aSr, RecordingB = bSr }); + resultRows.Add(new ResultRow { Metric = $"{prefix} Standard Deviation (STDEV)", RecordingA = fmtSd(m[0].StdDev), RecordingB = loaded.Count < 2 ? "" : fmtSd(m[1].StdDev) }); + string aCv = fmtRel(m[0].Cv); + string bCv = loaded.Count < 2 ? "" : fmtRel(m[1].Cv); + resultRows.Add(new ResultRow { Metric = $"{prefix} Coefficient of Variation (CV)", RecordingA = aCv, RecordingB = bCv }); + } + AddMsStats("MsBetweenDisplayChange", "MsBetweenDisplayChange"); + AddMsStats("MsBetweenPresents", "MsBetweenPresents"); + AddMsStats("MsGPUBusy", "MsGPUBusy"); + AddMsStats("MsUntilDisplayed", "MsUntilDisplayed"); + + ApplyResultComparisons(resultRows, loaded.Count == 2); + return GroupResultRows(resultRows); + }); + if (ViewModel.ActiveTab != "Statistics") + return; + if (builtRows.Count == 0) + { + ViewModel.StatisticsRows = []; + return; + } + ViewModel.StatisticsRows = [.. builtRows]; + StatisticsTreeGrid.ExpandAllNodes(); + } + private bool TryGetMetricsCached(string filePath, DateTime lastWriteUtc, string column, bool isFps, out Metrics metrics) + { + var cacheKey = (filePath, lastWriteUtc, column, isFps); + lock (_cacheLock) + { + if (_metricsCache.TryGetValue(cacheKey, out var cached)) + { + metrics = cached; + return true; + } + } + if (!LoadMetricColumn(filePath, lastWriteUtc, column, out var values)) + { + metrics = null; + return false; + } + var array = isFps + ? values.Where(v => v > 0).Select(v => 1000.0 / v).ToArray() + : values.ToArray(); + if (array.Length == 0) + { + metrics = null; + return false; + } + metrics = BenchmarkStatistics.CalculateMetrics(array, isFps); + lock (_cacheLock) + _metricsCache[cacheKey] = metrics; + return true; + } + + private static double NumericMetric(Metrics m, string label) => label switch + { + "0.1% Low Avg" => m.Low01, + "1% Low Avg" => m.Low1, + "Average (Arithmetic)" => m.AvgArithmetic, + "Average (Harmonic)" => m.AvgHarmonic, + "Minimum" => m.Min, + "Maximum" => m.Max, + "P0.1" => m.P01, + "P1" => m.P1, + "P5" => m.P5, + "P50 (Median)" => m.P50Median, + "P95" => m.P95, + "P99" => m.P99, + _ => 0 + }; + private static string FormatStat(double value, bool isFps) + { + if (value == 0) + return "—"; + return value.ToString(isFps ? "0.###" : "0.####", CultureInfo.InvariantCulture) + (isFps ? " FPS" : " ms"); + } + // ── CSV loading / stats ────────────────────────────────────────────────── + private bool GetHeaderIndex(string filePath, DateTime lastWriteUtc, out Dictionary headerIndex) + { + headerIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!File.Exists(filePath)) + return false; + lock (_cacheLock) + { + if (_headerCache.TryGetValue(filePath, out var cached) && cached.LastWriteUtc == lastWriteUtc) + { + headerIndex = cached.HeaderIndex; + return true; + } + } + try + { + using var reader = new StreamReader(filePath); + var headerLine = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(headerLine)) + return false; + var headers = ParseCsvLine(headerLine); + if (headers.Count == 0) + return false; + headerIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < headers.Count; i++) + { + var h = headers[i].Trim(); + if (string.IsNullOrEmpty(h)) + continue; + headerIndex[h] = i; + if (string.Equals(h, "Render Queue Depth", StringComparison.OrdinalIgnoreCase)) + headerIndex["Render Queue Depth (RQD)"] = i; + if (string.Equals(h, "Render Queue Depth (RQD)", StringComparison.OrdinalIgnoreCase)) + headerIndex["Render Queue Depth"] = i; + } + lock (_cacheLock) + { + _headerCache[filePath] = new CachedFile(filePath, lastWriteUtc, headerIndex); + } + return true; + } + catch + { + return false; + } + } + private bool LoadMetricColumn(string filePath, DateTime lastWriteUtc, string metric, out List values) + { + values = []; + if (!GetHeaderIndex(filePath, lastWriteUtc, out var headerIndex)) + return false; + if (!headerIndex.TryGetValue(metric, out int idx)) + return false; + var key = (filePath, lastWriteUtc, metric); + lock (_cacheLock) + { + if (_columnCache.TryGetValue(key, out var cached)) + { + values = cached; + return cached.Count > 0; + } + } + try + { + using var reader = new StreamReader(filePath); + _ = reader.ReadLine(); + var list = new List(capacity: 4096); + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(line)) + continue; + var cols = ParseCsvLine(line); + if (idx < 0 || idx >= cols.Count) + continue; + if (double.TryParse(cols[idx], NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) + list.Add(v); + } + lock (_cacheLock) + { + _columnCache[key] = list; + } + values = list; + return list.Count > 0; + } + catch + { + return false; + } + } + private bool LoadAnalysisColumns(string filePath, DateTime lastWriteUtc) + { + if (!GetHeaderIndex(filePath, lastWriteUtc, out var headerIndex)) + return false; + + string[] metrics = + [ + "MsBetweenDisplayChange", + "MsBetweenPresents", + "MsGPUBusy", + "MsUntilDisplayed" + ]; + List<(string Metric, int Index, List Values)> columns = []; + foreach (string metric in metrics) + { + if (headerIndex.TryGetValue(metric, out int index)) + columns.Add((metric, index, new List(4096))); + } + if (columns.Count == 0) + return false; + + lock (_cacheLock) + { + if (columns.All(column => + _columnCache.ContainsKey((filePath, lastWriteUtc, column.Metric)))) + return true; + } + + try + { + using var reader = new StreamReader(filePath); + _ = reader.ReadLine(); + while (!reader.EndOfStream) + { + string line = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(line)) + continue; + List values = ParseCsvLine(line); + for (int index = 0; index < columns.Count; index++) + { + var column = columns[index]; + if (column.Index < values.Count && + double.TryParse(values[column.Index], NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) + column.Values.Add(value); + } + } + + lock (_cacheLock) + { + foreach (var column in columns) + _columnCache[(filePath, lastWriteUtc, column.Metric)] = column.Values; + } + return true; + } + catch + { + return false; + } + } + // ── Process name ───────────────────────────────────────────────────────── + private void ProcessAutoSuggestBox_GotFocus(object sender, RoutedEventArgs e) + { + ProcessAutoSuggestBox.IsSuggestionListOpen = ViewModel.ProcessSuggestions.Count > 0; + } + + private void PresentingProcesses_ProcessesChanged(object sender, EventArgs e) + { + DispatcherQueue.TryEnqueue(() => + { + if (ViewModel.ActiveTab == "Recordings") + ViewModel.SetRecordableProcesses(PresentingProcesses.GetRecordableProcesses()); + }); + } + + private void ProcessAutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput) + { + ViewModel.ProcessName = sender.Text; + sender.IsSuggestionListOpen = ViewModel.ProcessSuggestions.Count > 0; + } + } + private void ProcessAutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + sender.Text = args.SelectedItem as string ?? string.Empty; + ViewModel.ProcessName = sender.Text; + } + private async void BenchmarksSelectorBar_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + var selectedItem = args.SelectedItem ?? sender.SelectedItem; + if (ReferenceEquals(selectedItem, RecordingsTab)) + { + ViewModel.ActiveTab = "Recordings"; + PresentingProcesses.Start(); + ViewModel.SetRecordableProcesses( + PresentingProcesses.GetRecordableProcesses(refreshRunningProcesses: true)); + } + else if (ReferenceEquals(selectedItem, AnalysisTab)) + { + ViewModel.ActiveTab = "Analysis"; + if (HeaderFpsColorPicker is not null) + ReapplyColorPickerTemplate(HeaderFpsColorPicker, ViewModel.FpsColor); + if (HeaderFpsColorPicker2 is not null) + ReapplyColorPickerTemplate(HeaderFpsColorPicker2, ViewModel.FpsColor2); + var selected = GetSelectedRecordings(); + ViewModel.SetSelectedRecordings(selected); + if (selected.Count is > 0 and <= 2) + await RenderAnalysisChartsForSelection(selected); + } + else if (ReferenceEquals(selectedItem, StatisticsTab)) + { + ViewModel.ActiveTab = "Statistics"; + var selected = GetSelectedRecordings(); + ViewModel.SetSelectedRecordings(selected); + if (selected.Count is > 0 and <= 2) + await RefreshStatisticsTable(); + } + } + private static void ReapplyColorPickerTemplate(DropdownColorPicker picker, Windows.UI.Color color) + { + var template = picker.Template; + if (template is null) + return; + + picker.Template = null; + picker.Template = template; + picker.ApplyTemplate(); + picker.Color = color; + } +} +