From 3f0f135255e37e5b67d43a3e30142971908635be Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 15 Oct 2026 13:25:35 +0200 Subject: [PATCH 1/7] fix: P/E cores from the highest EfficiencyClass, AMD CCDs from real L3 groups The legacy topology treated the lowest EfficiencyClass as the performance core, swapping P- and E-cores on hybrid Intel CPUs. AMD CCDs were guessed as 8 cores each, which split 6+6 parts (7900X3D, 9900X3D) into 8+4; they now come from the L3 groups Windows reports, with the guess kept only as a fallback. The topology snapshot also keeps each L3 group's size. --- Models/CpuSelection.cs | 14 +++- Services/CpuTopologyService.cs | 61 +++++++++++++-- Services/WindowsCpuTopologyNativeLayout.cs | 6 +- Services/WindowsCpuTopologyProvider.cs | 13 +++- .../CpuTopologyServiceTopologyFixTests.cs | 78 +++++++++++++++++++ .../TopologyFixtures.cs | 70 +++++++++++++++++ 6 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 Tests/ThreadPilot.Core.Tests/CpuTopologyServiceTopologyFixTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/TopologyFixtures.cs diff --git a/Models/CpuSelection.cs b/Models/CpuSelection.cs index 0d1e78d..ce45f73 100644 --- a/Models/CpuSelection.cs +++ b/Models/CpuSelection.cs @@ -59,6 +59,7 @@ public sealed class CpuTopologySnapshot private readonly IReadOnlyDictionary lastLevelCacheIndexesByProcessor; private readonly IReadOnlyDictionary packageIndexesByProcessor; private readonly IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor; + private readonly IReadOnlyDictionary lastLevelCacheSizesByIndex; private CpuTopologySnapshot( IReadOnlyList logicalProcessors, @@ -70,7 +71,8 @@ private CpuTopologySnapshot( IReadOnlyDictionary packageIndexesByProcessor, IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor, CpuTopologySignature signature, - IReadOnlyList reservedLogicalProcessorIndexes) + IReadOnlyList reservedLogicalProcessorIndexes, + IReadOnlyDictionary lastLevelCacheSizesByIndex) { this.LogicalProcessors = logicalProcessors; this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; @@ -82,6 +84,7 @@ private CpuTopologySnapshot( this.smtSiblingGlobalIndexesByProcessor = smtSiblingGlobalIndexesByProcessor; this.Signature = signature; this.ReservedLogicalProcessorIndexes = reservedLogicalProcessorIndexes; + this.lastLevelCacheSizesByIndex = lastLevelCacheSizesByIndex; } public IReadOnlyList LogicalProcessors { get; } @@ -100,7 +103,8 @@ public static CpuTopologySnapshot Create( IReadOnlyDictionary? lastLevelCacheIndexes = null, IReadOnlyDictionary? packageIndexes = null, IReadOnlyDictionary>? smtSiblingGlobalIndexes = null, - IEnumerable? reservedLogicalProcessorIndexes = null) + IEnumerable? reservedLogicalProcessorIndexes = null, + IReadOnlyDictionary? lastLevelCacheSizes = null) { ArgumentNullException.ThrowIfNull(logicalProcessors); @@ -177,7 +181,8 @@ public static CpuTopologySnapshot Create( .Where(knownGlobalIndexes.Contains) .Distinct() .OrderBy(index => index) - .ToList()); + .ToList(), + lastLevelCacheSizes?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary()); } public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) => @@ -195,6 +200,9 @@ public bool TryGetNumaNodeIndex(ProcessorRef processor, out int numaNodeIndex) = public bool TryGetLastLevelCacheIndex(ProcessorRef processor, out int lastLevelCacheIndex) => this.lastLevelCacheIndexesByProcessor.TryGetValue(processor, out lastLevelCacheIndex); + public bool TryGetLastLevelCacheSize(int lastLevelCacheIndex, out ulong cacheSizeBytes) => + this.lastLevelCacheSizesByIndex.TryGetValue(lastLevelCacheIndex, out cacheSizeBytes); + public bool TryGetPackageIndex(ProcessorRef processor, out int packageIndex) => this.packageIndexesByProcessor.TryGetValue(processor, out packageIndex); diff --git a/Services/CpuTopologyService.cs b/Services/CpuTopologyService.cs index 2a44da2..2406135 100644 --- a/Services/CpuTopologyService.cs +++ b/Services/CpuTopologyService.cs @@ -14,6 +14,7 @@ namespace ThreadPilot.Services public class CpuTopologyService : ICpuTopologyService { private readonly ILogger logger; + private readonly ICpuTopologyProvider? topologyProvider; private readonly SemaphoreSlim detectSemaphore = new(1, 1); private CpuTopologyModel? currentTopology; private DateTime topologyCachedAtUtc = DateTime.MinValue; @@ -72,9 +73,10 @@ private static extern bool GetLogicalProcessorInformationEx( IntPtr buffer, ref int returnedLength); - public CpuTopologyService(ILogger logger) + public CpuTopologyService(ILogger logger, ICpuTopologyProvider? topologyProvider = null) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.topologyProvider = topologyProvider; } public async Task DetectTopologyAsync() @@ -290,7 +292,7 @@ private bool TryDetectCoresViaWindowsApi(CpuTopologyModel topology) } this.ApplyHyperThreadingFromPhysicalMapping(topology); - this.ApplyCoreTypeFromEfficiencyClass(topology, discovered); + ApplyCoreTypeFromEfficiencyClass(topology, discovered); this.logger.LogInformation( "Detected CPU topology via GetLogicalProcessorInformationEx: {LogicalCpuCount} logical CPUs, {PhysicalCpuCount} physical CPUs", @@ -345,13 +347,13 @@ private void ApplyHyperThreadingFromPhysicalMapping(CpuTopologyModel topology) } } - private void ApplyCoreTypeFromEfficiencyClass( + internal static void ApplyCoreTypeFromEfficiencyClass( CpuTopologyModel topology, List<(int PhysicalCpuId, int LogicalCpuId, byte EfficiencyClass)> discovered) { var byPhysical = discovered .GroupBy(d => d.PhysicalCpuId) - .Select(g => new { PhysicalCpuId = g.Key, EfficiencyClass = g.Min(x => x.EfficiencyClass) }) + .Select(g => new { PhysicalCpuId = g.Key, EfficiencyClass = g.Max(x => x.EfficiencyClass) }) .ToList(); var classes = byPhysical @@ -365,7 +367,8 @@ private void ApplyCoreTypeFromEfficiencyClass( return; } - byte performanceClass = classes.Min(); + // A higher EfficiencyClass is the more performant core (PROCESSOR_RELATIONSHIP docs). + byte performanceClass = classes.Max(); foreach (var logicalCpu in topology.LogicalCores) { byte classValue = byPhysical.First(x => x.PhysicalCpuId == logicalCpu.PhysicalCoreId).EfficiencyClass; @@ -525,6 +528,12 @@ private async Task DetectAmdCcdAsync(CpuTopologyModel topology) { if (topology.CpuBrand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) { + if (await this.TryAssignCcdsFromL3GroupsAsync(topology)) + { + return; + } + + // Fallback estimate, display only: Game Boost never uses it (it reads CpuTopologySnapshot). // AMD CCD detection - improved heuristic // Only assign CCD if we actually have multiple CCDs var totalPhysicalCores = topology.TotalPhysicalCores; @@ -562,6 +571,48 @@ private async Task DetectAmdCcdAsync(CpuTopologyModel topology) } } + // Each Zen CCD (CCX on Zen 2) has its own L3, so the real L3 groups replace the 8-cores-per-CCD guess, + // which split 6+6 parts such as the 7900X3D into 8+4. + internal async Task TryAssignCcdsFromL3GroupsAsync(CpuTopologyModel topology) + { + if (this.topologyProvider == null) + { + return false; + } + + var snapshot = await this.topologyProvider.GetTopologySnapshotAsync(); + var l3ByLogicalCore = new Dictionary(); + foreach (var processor in snapshot.LogicalProcessors) + { + if (snapshot.TryGetLastLevelCacheIndex(processor, out var l3Index)) + { + l3ByLogicalCore[processor.GlobalIndex] = l3Index; + } + } + + if (topology.LogicalCores.Count == 0 || topology.LogicalCores.Any(core => !l3ByLogicalCore.ContainsKey(core.LogicalCoreId))) + { + return false; + } + + // Number the groups by their lowest CPU, so CCD 0 is always the one holding CPU 0. + var ccdByL3 = topology.LogicalCores + .GroupBy(core => l3ByLogicalCore[core.LogicalCoreId]) + .OrderBy(group => group.Min(core => core.LogicalCoreId)) + .Select((group, ccdId) => (group.Key, ccdId)) + .ToDictionary(item => item.Key, item => item.ccdId); + var hasMultipleL3Groups = ccdByL3.Count > 1; + + foreach (var core in topology.LogicalCores) + { + core.CcdId = hasMultipleL3Groups ? ccdByL3[l3ByLogicalCore[core.LogicalCoreId]] : null; + core.CoreType = CpuCoreType.Zen3; // Default assumption, as in the estimate below + } + + this.logger.LogInformation("Detected AMD L3 groups from Windows topology: {L3GroupCount}", ccdByL3.Count); + return true; + } + private void DetectHyperThreading(CpuTopologyModel topology) { try diff --git a/Services/WindowsCpuTopologyNativeLayout.cs b/Services/WindowsCpuTopologyNativeLayout.cs index 6717520..55dd48f 100644 --- a/Services/WindowsCpuTopologyNativeLayout.cs +++ b/Services/WindowsCpuTopologyNativeLayout.cs @@ -81,9 +81,13 @@ public static IReadOnlyList ReadProcessorRelationshipProcessors(In return ReadProcessorsFromGroupMasks(relationshipPtr, ProcessorGroupMaskOffset, groupCount).ToList(); } - public static bool TryReadL3CacheProcessors(IntPtr relationshipPtr, out IReadOnlyList processors) + public static bool TryReadL3CacheProcessors(IntPtr relationshipPtr, out IReadOnlyList processors) => + TryReadL3CacheProcessors(relationshipPtr, out processors, out _); + + public static bool TryReadL3CacheProcessors(IntPtr relationshipPtr, out IReadOnlyList processors, out uint cacheSizeBytes) { var cache = Marshal.PtrToStructure(relationshipPtr); + cacheSizeBytes = cache.CacheSize; if (cache.Level != 3 || cache.GroupCount == 0) { processors = []; diff --git a/Services/WindowsCpuTopologyProvider.cs b/Services/WindowsCpuTopologyProvider.cs index 6d0c70e..d248424 100644 --- a/Services/WindowsCpuTopologyProvider.cs +++ b/Services/WindowsCpuTopologyProvider.cs @@ -91,6 +91,7 @@ private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) var coreIndexes = new Dictionary(); var numaNodeIndexes = new Dictionary(); var lastLevelCacheIndexes = new Dictionary(); + var lastLevelCacheSizes = new Dictionary(); var packageIndexes = new Dictionary(); var smtSiblingGlobalIndexes = new Dictionary>(); var reservedLogicalProcessorIndexes = new HashSet(); @@ -111,6 +112,7 @@ private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) coreIndexes, numaNodeIndexes, lastLevelCacheIndexes, + lastLevelCacheSizes, packageIndexes, smtSiblingGlobalIndexes, cancellationToken); @@ -159,7 +161,8 @@ private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) lastLevelCacheIndexes, packageIndexes, smtSiblingGlobalIndexes, - reservedLogicalProcessorIndexes); + reservedLogicalProcessorIndexes, + lastLevelCacheSizes); } private void ReadCpuSetInformation( @@ -236,6 +239,7 @@ private void ReadLogicalProcessorRelationships( IDictionary coreIndexes, IDictionary numaNodeIndexes, IDictionary lastLevelCacheIndexes, + IDictionary lastLevelCacheSizes, IDictionary packageIndexes, IDictionary> smtSiblingGlobalIndexes, CancellationToken cancellationToken) @@ -296,8 +300,9 @@ private void ReadLogicalProcessorRelationships( packageIndexes); break; case LogicalProcessorRelationship.RelationCache: - if (TryReadL3CacheProcessors(itemPtr, out var cacheProcessors)) + if (TryReadL3CacheProcessors(itemPtr, out var cacheProcessors, out var cacheSizeBytes)) { + lastLevelCacheSizes[lastLevelCacheIndex] = cacheSizeBytes; foreach (var processor in cacheProcessors) { logicalProcessors.Add(processor); @@ -365,9 +370,9 @@ private void ReadIndexedProcessorRelationship( } } - private static bool TryReadL3CacheProcessors(IntPtr itemPtr, out IReadOnlyList processors) + private static bool TryReadL3CacheProcessors(IntPtr itemPtr, out IReadOnlyList processors, out uint cacheSizeBytes) { - return WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(IntPtr.Add(itemPtr, 8), out processors); + return WindowsCpuTopologyNativeLayout.TryReadL3CacheProcessors(IntPtr.Add(itemPtr, 8), out processors, out cacheSizeBytes); } private void ReadNumaNodeRelationship( diff --git a/Tests/ThreadPilot.Core.Tests/CpuTopologyServiceTopologyFixTests.cs b/Tests/ThreadPilot.Core.Tests/CpuTopologyServiceTopologyFixTests.cs new file mode 100644 index 0000000..1e9eccc --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/CpuTopologyServiceTopologyFixTests.cs @@ -0,0 +1,78 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CpuTopologyServiceTopologyFixTests + { + [Fact] + public void EfficiencyClass_HigherClassIsThePerformanceCore() + { + var topology = new CpuTopologyModel(); + for (var i = 0; i < 4; i++) + { + topology.LogicalCores.Add(new CpuCoreModel { LogicalCoreId = i, PhysicalCoreId = i }); + } + + CpuTopologyService.ApplyCoreTypeFromEfficiencyClass( + topology, + [(0, 0, (byte)1), (1, 1, (byte)1), (2, 2, (byte)0), (3, 3, (byte)0)]); + + Assert.Equal( + [CpuCoreType.PerformanceCore, CpuCoreType.PerformanceCore, CpuCoreType.EfficiencyCore, CpuCoreType.EfficiencyCore], + topology.LogicalCores.Select(core => core.CoreType)); + } + + [Fact] + public async Task AmdCcds_ComeFromRealL3Groups_SixPlusSix() + { + var snapshot = TopologyFixtures.Amd([6, 6], cacheSizes: null); + var service = new CpuTopologyService(NullLogger.Instance, new FixedTopologyProvider(snapshot)); + var topology = CreateLegacyTopology(24); + + Assert.True(await service.TryAssignCcdsFromL3GroupsAsync(topology)); + + Assert.Equal(12, topology.LogicalCores.Count(core => core.CcdId == 0)); + Assert.Equal(12, topology.LogicalCores.Count(core => core.CcdId == 1)); + } + + [Fact] + public async Task AmdCcds_SingleL3_HasNoCcdIds() + { + var snapshot = TopologyFixtures.Amd([8], cacheSizes: null); + var service = new CpuTopologyService(NullLogger.Instance, new FixedTopologyProvider(snapshot)); + var topology = CreateLegacyTopology(16); + + Assert.True(await service.TryAssignCcdsFromL3GroupsAsync(topology)); + + Assert.All(topology.LogicalCores, core => Assert.Null(core.CcdId)); + } + + [Fact] + public async Task AmdCcds_WithoutL3Data_FallsBackToEstimate() + { + var snapshot = TopologyFixtures.Amd([8, 8], cacheSizes: null, withL3Indexes: false); + var service = new CpuTopologyService(NullLogger.Instance, new FixedTopologyProvider(snapshot)); + + Assert.False(await service.TryAssignCcdsFromL3GroupsAsync(CreateLegacyTopology(32))); + } + + private static CpuTopologyModel CreateLegacyTopology(int logicalCount) + { + var topology = new CpuTopologyModel { CpuBrand = "AMD Ryzen" }; + for (var i = 0; i < logicalCount; i++) + { + topology.LogicalCores.Add(new CpuCoreModel { LogicalCoreId = i, PhysicalCoreId = i / 2 }); + } + + return topology; + } + + private sealed class FixedTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) => + Task.FromResult(snapshot); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/TopologyFixtures.cs b/Tests/ThreadPilot.Core.Tests/TopologyFixtures.cs new file mode 100644 index 0000000..1307c9f --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/TopologyFixtures.cs @@ -0,0 +1,70 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + + internal static class TopologyFixtures + { + /// AMD with SMT: each entry of coresPerL3 is one L3 group (CCD), two logical CPUs per core. + public static CpuTopologySnapshot Amd( + int[] coresPerL3, + ulong[]? cacheSizes, + bool withL3Indexes = true, + bool withCpuSetIds = true, + int[]? reserved = null) + { + var processors = new List(); + var l3Indexes = new Dictionary(); + var coreIndexes = new Dictionary(); + var core = 0; + for (var l3 = 0; l3 < coresPerL3.Length; l3++) + { + for (var c = 0; c < coresPerL3[l3]; c++, core++) + { + for (var thread = 0; thread < 2; thread++) + { + var index = (core * 2) + thread; + var processor = new ProcessorRef(0, (byte)index, index); + processors.Add(processor); + coreIndexes[processor] = core; + l3Indexes[processor] = l3; + } + } + } + + return CpuTopologySnapshot.Create( + processors, + cpuSetIds: withCpuSetIds ? processors.ToDictionary(p => p, p => (uint)(256 + p.GlobalIndex)) : null, + coreIndexes: coreIndexes, + lastLevelCacheIndexes: withL3Indexes ? l3Indexes : null, + reservedLogicalProcessorIndexes: reserved, + lastLevelCacheSizes: cacheSizes?.Select((size, index) => (size, index)).ToDictionary(x => x.index, x => x.size)); + } + + /// Intel hybrid: P-cores with HT first (EfficiencyClass 1), then single-thread E-cores (class 0). + public static CpuTopologySnapshot IntelHybrid(int performanceCores, int efficiencyCores, bool withL3Indexes = true) + { + var processors = new List(); + var classes = new Dictionary(); + var coreIndexes = new Dictionary(); + var index = 0; + for (var core = 0; core < performanceCores + efficiencyCores; core++) + { + var isPerformance = core < performanceCores; + for (var thread = 0; thread < (isPerformance ? 2 : 1); thread++, index++) + { + var processor = new ProcessorRef(0, (byte)index, index); + processors.Add(processor); + classes[processor] = isPerformance ? (byte)1 : (byte)0; + coreIndexes[processor] = core; + } + } + + return CpuTopologySnapshot.Create( + processors, + cpuSetIds: processors.ToDictionary(p => p, p => (uint)(256 + p.GlobalIndex)), + efficiencyClasses: classes, + coreIndexes: coreIndexes, + lastLevelCacheIndexes: withL3Indexes ? processors.ToDictionary(p => p, _ => 0) : null); + } + } +} From f8d6f19dba3c8e435a071372cd13238933cedaf1 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Thu, 15 Oct 2026 16:17:41 +0200 Subject: [PATCH 2/7] feat: Game Boost, CPU Sets for the game and selected background apps While a detected game runs, Game Boost sets process default CPU Sets from the CPU topology: on AMD X3D with one larger L3 the game goes to the V-Cache CCD, on Intel hybrid CPUs to the P-cores, and selected background apps (browsers, sync clients, updaters, launchers) to the other cores. On dual-CCD parts with equal L3 only the background moves, unless Isolation mode also keeps the game on CCD0. Single-L3 and unreadable topologies are left to Windows. Above Normal priority for the game is optional; never High. Each change is written to a journal before it is made and undone when the game exits, Game Boost is turned off or ThreadPilot closes, and on the next start after a crash. Only values still exactly as Game Boost set them are restored, never one a user rule or a manual change has taken over since. Persistent rules and Game Boost share one apply gate, and a rule always wins over Game Boost for the setting it covers. The switch, mode and lists are on the Tweaks page, off by default, and take effect without a restart. --- Locales/de-DE.xaml | 19 + Locales/en-US.xaml | 19 + Locales/es-ES.xaml | 19 + Locales/fr-FR.xaml | 19 + Locales/it-IT.xaml | 19 + Locales/ru-RU.xaml | 19 + Locales/zh-CN.xaml | 19 + MainWindow.Behaviors.partial.cs | 21 +- Models/ApplicationSettingsModel.cs | 27 + Services/GameBoostPolicy.cs | 184 +++++ Services/GameBoostProcessLists.cs | 109 +++ Services/GameSessionService.cs | 641 ++++++++++++++++++ Services/PersistentRulesEngine.cs | 6 +- Services/ProcessPolicyApplyGate.cs | 34 + Services/ProcessPriorityWriteSource.cs | 1 + Services/ServiceConfiguration.cs | 3 + .../GameBoostPolicyTests.cs | 179 +++++ .../GameBoostProcessListsTests.cs | 56 ++ .../GameSessionServiceTests.cs | 376 ++++++++++ .../PersistentRulesEngineTests.cs | 26 + .../SystemTweaksGameBoostTests.cs | 94 +++ ViewModels/SystemTweaksViewModel.GameBoost.cs | 183 +++++ ViewModels/SystemTweaksViewModel.cs | 13 +- Views/SystemTweaksView.xaml | 101 ++- 24 files changed, 2182 insertions(+), 5 deletions(-) create mode 100644 Services/GameBoostPolicy.cs create mode 100644 Services/GameBoostProcessLists.cs create mode 100644 Services/GameSessionService.cs create mode 100644 Services/ProcessPolicyApplyGate.cs create mode 100644 Tests/ThreadPilot.Core.Tests/GameBoostPolicyTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/GameBoostProcessListsTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/SystemTweaksGameBoostTests.cs create mode 100644 ViewModels/SystemTweaksViewModel.GameBoost.cs diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index e28c2d4..88f5d72 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -558,6 +558,25 @@ Einstellungen öffnen Ein Aus + Game Boost + Während ein Spiel läuft, setzt Game Boost die Standard-CPU-Sets des Spiels und ausgewählter Hintergrund-Apps, damit sie auf getrennten Kernen laufen. Windows plant weiterhin jeden Thread darin, und beim Beenden des Spiels wird alles wiederhergestellt. + Modus + Automatisch + Isolation + Isolation verschiebt zusätzlich weitere Neben-Apps (Launcher-Helfer, Mediaplayer, Office-Apps) und hält das Spiel auf CPUs mit zwei gleichen CCDs auf CCD0. + Spielpriorität auf Höher als normal setzen + Experimentell: in deinen Spielen messen. Niemals Hoch oder Echtzeit. + Spiele und Apps + Manuell hinzugefügte Spiele (ein Programmname oder vollständiger Pfad pro Zeile) + Spiele, die nie geboostet werden + Hintergrund-Apps, die von den Spielkernen verschoben werden + Listen speichern + Game-Boost-Einstellungen konnten nicht gespeichert werden + Spiel auf dem CCD mit 3D V-Cache ({0} CPUs), ausgewählte Hintergrund-Apps auf dem anderen CCD ({1} CPUs). + Spiel auf CCD0 ({0} CPUs), Hintergrund-Apps auf dem anderen CCD ({1} CPUs). + Windows platziert das Spiel; ausgewählte Hintergrund-Apps verlassen CCD0 ({0} CPUs). Isolation hält zusätzlich das Spiel auf CCD0. + Spiel auf den P-Kernen ({0} CPUs), ausgewählte Hintergrund-Apps auf den E-Kernen ({1} CPUs). + Auf dieser CPU entscheidet Windows über die Kerne; Game Boost setzt nur die optionale Priorität. Von Windows verwaltet Windows-Einstellungen konnten nicht geöffnet werden Einstellungen für {0} geöffnet diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 4e87b58..8fca21c 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -558,6 +558,25 @@ Open settings On Off + Game Boost + While a game runs, sets the default CPU Sets of the game and of selected background apps so they run on separate cores. Windows still schedules every thread inside them, and everything is restored when the game closes. + Mode + Automatic + Isolation + Isolation also moves more secondary apps (launcher helpers, media players, office apps) and, on CPUs with two equal CCDs, keeps the game on CCD0. + Raise game priority to Above Normal + Experimental: measure it on your games. Never High or Realtime. + Games and apps + Games added by hand (one executable name or full path per line) + Games never boosted + Background apps moved off the game's cores + Save lists + Could not save Game Boost settings + Game on the CCD with 3D V-Cache ({0} CPUs), selected background apps on the other CCD ({1} CPUs). + Game on CCD0 ({0} CPUs), background apps on the other CCD ({1} CPUs). + Windows places the game; selected background apps move off CCD0 ({0} CPUs). Isolation also keeps the game on CCD0. + Game on the P-cores ({0} CPUs), selected background apps on the E-cores ({1} CPUs). + On this CPU Windows decides the cores; Game Boost only applies the optional priority. Managed by Windows Could not open Windows settings Opened settings for {0} diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 5761000..1087a59 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -558,6 +558,25 @@ Abrir configuración Activado Desactivado + Game Boost + Mientras se ejecuta un juego, establece los CPU Sets predeterminados del juego y de las aplicaciones en segundo plano seleccionadas para que usen núcleos separados. Windows sigue planificando cada hilo dentro de ellos y todo se restaura al cerrar el juego. + Modo + Automático + Aislamiento + Aislamiento también mueve otras aplicaciones secundarias (ayudantes de lanzadores, reproductores multimedia, aplicaciones de oficina) y, en CPU con dos CCD iguales, mantiene el juego en el CCD0. + Subir la prioridad del juego a Por encima de lo normal + Experimental: mídelo en tus juegos. Nunca Alta ni Tiempo real. + Juegos y aplicaciones + Juegos añadidos a mano (un nombre de ejecutable o ruta completa por línea) + Juegos que nunca se optimizan + Aplicaciones en segundo plano movidas fuera de los núcleos del juego + Guardar listas + No se pudo guardar la configuración de Game Boost + Juego en el CCD con 3D V-Cache ({0} CPU), aplicaciones en segundo plano seleccionadas en el otro CCD ({1} CPU). + Juego en el CCD0 ({0} CPU), aplicaciones en segundo plano en el otro CCD ({1} CPU). + Windows coloca el juego; las aplicaciones en segundo plano seleccionadas salen del CCD0 ({0} CPU). Aislamiento también mantiene el juego en el CCD0. + Juego en los núcleos P ({0} CPU), aplicaciones en segundo plano seleccionadas en los núcleos E ({1} CPU). + En esta CPU Windows decide los núcleos; Game Boost solo aplica la prioridad opcional. Administrado por Windows No se pudo abrir la configuración de Windows Configuración abierta para {0} diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 472fce1..6bc06a7 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -558,6 +558,25 @@ Ouvrir les paramètres Activé Désactivé + Game Boost + Pendant qu'un jeu tourne, définit les CPU Sets par défaut du jeu et des applications d'arrière-plan sélectionnées pour qu'ils s'exécutent sur des cœurs séparés. Windows planifie toujours chaque thread à l'intérieur, et tout est restauré à la fermeture du jeu. + Mode + Automatique + Isolation + L'isolation déplace aussi d'autres applications secondaires (assistants de lanceurs, lecteurs multimédias, applications bureautiques) et, sur les CPU à deux CCD identiques, garde le jeu sur le CCD0. + Passer la priorité du jeu à Supérieure à la normale + Expérimental : mesurez-le sur vos jeux. Jamais Haute ni Temps réel. + Jeux et applications + Jeux ajoutés manuellement (un nom d'exécutable ou un chemin complet par ligne) + Jeux jamais optimisés + Applications d'arrière-plan déplacées hors des cœurs du jeu + Enregistrer les listes + Impossible d'enregistrer les paramètres de Game Boost + Jeu sur le CCD avec 3D V-Cache ({0} CPU), applications d'arrière-plan sélectionnées sur l'autre CCD ({1} CPU). + Jeu sur le CCD0 ({0} CPU), applications d'arrière-plan sur l'autre CCD ({1} CPU). + Windows place le jeu ; les applications d'arrière-plan sélectionnées quittent le CCD0 ({0} CPU). L'isolation garde aussi le jeu sur le CCD0. + Jeu sur les cœurs P ({0} CPU), applications d'arrière-plan sélectionnées sur les cœurs E ({1} CPU). + Sur ce CPU, Windows choisit les cœurs ; Game Boost applique seulement la priorité facultative. Géré par Windows Impossible d'ouvrir les paramètres Windows Paramètres ouverts pour {0} diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index d07a8cd..edd0868 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -558,6 +558,25 @@ Apri impostazioni Attivato Disattivato + Game Boost + Mentre un gioco è in esecuzione, imposta i CPU Set predefiniti del gioco e delle app in background selezionate, così girano su core separati. Windows continua a gestire ogni thread al loro interno, e tutto viene ripristinato quando il gioco si chiude. + Modalità + Automatico + Isolamento + Isolamento sposta anche altre app secondarie (helper dei launcher, lettori multimediali, app per ufficio) e, sulle CPU con due CCD uguali, tiene il gioco sul CCD0. + Alza la priorità del gioco a Superiore al normale + Sperimentale: misuralo sui tuoi giochi. Mai Alta o Tempo reale. + Giochi e app + Giochi aggiunti a mano (un nome di eseguibile o un percorso completo per riga) + Giochi da non ottimizzare mai + App in background spostate dai core del gioco + Salva elenchi + Impossibile salvare le impostazioni di Game Boost + Gioco sul CCD con 3D V-Cache ({0} CPU), app in background selezionate sull'altro CCD ({1} CPU). + Gioco sul CCD0 ({0} CPU), app in background sull'altro CCD ({1} CPU). + Il gioco lo posiziona Windows; le app in background selezionate lasciano il CCD0 ({0} CPU). Isolamento tiene anche il gioco sul CCD0. + Gioco sui P-core ({0} CPU), app in background selezionate sugli E-core ({1} CPU). + Su questa CPU i core li decide Windows; Game Boost applica solo la priorità facoltativa. Gestito da Windows Impossibile aprire le impostazioni di Windows Impostazioni aperte per {0} diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 2f3854e..e9f4bbd 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -558,6 +558,25 @@ Открыть параметры Вкл. Откл. + Game Boost + Пока запущена игра, задаёт наборы ЦП (CPU Sets) по умолчанию для игры и выбранных фоновых приложений, чтобы они работали на разных ядрах. Windows по-прежнему распределяет каждый поток внутри них, а после закрытия игры всё восстанавливается. + Режим + Автоматически + Изоляция + Изоляция также переносит другие второстепенные приложения (помощники лаунчеров, медиаплееры, офисные приложения), а на ЦП с двумя одинаковыми CCD оставляет игру на CCD0. + Повысить приоритет игры до «Выше среднего» + Экспериментально: проверьте на своих играх. Никогда «Высокий» или «Реального времени». + Игры и приложения + Игры, добавленные вручную (имя исполняемого файла или полный путь, по одному в строке) + Игры, которые никогда не ускоряются + Фоновые приложения, переносимые с ядер игры + Сохранить списки + Не удалось сохранить настройки Game Boost + Игра на CCD с 3D V-Cache ({0} ЦП), выбранные фоновые приложения на другом CCD ({1} ЦП). + Игра на CCD0 ({0} ЦП), фоновые приложения на другом CCD ({1} ЦП). + Игру размещает Windows; выбранные фоновые приложения уходят с CCD0 ({0} ЦП). Изоляция также оставляет игру на CCD0. + Игра на P-ядрах ({0} ЦП), выбранные фоновые приложения на E-ядрах ({1} ЦП). + На этом ЦП ядра выбирает Windows; Game Boost применяет только необязательный приоритет. Управляется Windows Не удалось открыть параметры Windows Открыты параметры для {0} diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 53407cb..91d8719 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -558,6 +558,25 @@ 打开设置 开 关 + 游戏加速 + 游戏运行时,为游戏和选定的后台应用设置默认 CPU 集,使它们运行在不同的核心上。Windows 仍在其中调度每个线程,游戏关闭后一切都会恢复。 + 模式 + 自动 + 隔离 + 隔离模式还会移动更多次要应用(启动器辅助进程、媒体播放器、办公应用),并在两个 CCD 相同的 CPU 上让游戏保持在 CCD0。 + 将游戏优先级提高到“高于正常” + 实验性功能:请在你的游戏中实测。绝不使用“高”或“实时”。 + 游戏和应用 + 手动添加的游戏(每行一个可执行文件名或完整路径) + 从不加速的游戏 + 从游戏核心移出的后台应用 + 保存列表 + 无法保存游戏加速设置 + 游戏在带 3D V-Cache 的 CCD 上({0} 个 CPU),选定的后台应用在另一个 CCD 上({1} 个 CPU)。 + 游戏在 CCD0 上({0} 个 CPU),后台应用在另一个 CCD 上({1} 个 CPU)。 + 游戏由 Windows 放置;选定的后台应用移出 CCD0({0} 个 CPU)。隔离模式还会让游戏保持在 CCD0。 + 游戏在 P 核上({0} 个 CPU),选定的后台应用在 E 核上({1} 个 CPU)。 + 在此 CPU 上由 Windows 决定核心;游戏加速只应用可选的优先级。 由 Windows 管理 无法打开 Windows 设置 已打开 {0} 的设置 diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 09ac439..793bd56 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -504,6 +504,16 @@ private async Task InitializeServicesAsync() await this.InitializeMonitoringAsync(); this.LogDebug("Monitoring initialized successfully"); + // Game Boost has its own switch, independent of automation monitoring; starting also undoes a crashed run. + try + { + await this.serviceProvider.GetRequiredService().StartAsync(); + } + catch (Exception ex) + { + this.LogDebug($"Game Boost startup failed: {ex.Message}"); + } + if (this.skipProcessMonitoringDuringStartup || !this.settingsService.Settings.EnableAutomationMonitoring) { this.LogDebug(this.skipProcessMonitoringDuringStartup @@ -736,7 +746,16 @@ private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = tr this.LogDebug("Starting graceful shutdown..."); this.selfResourceManagementService.RestoreForegroundMode(); - // 1. Stop monitoring services + // 1. Stop monitoring services; Game Boost first restores what it changed + try + { + await this.serviceProvider.GetRequiredService().StopAsync(); + } + catch (Exception ex) + { + this.LogDebug($"Error stopping Game Boost: {ex.Message}"); + } + try { this.LogDebug("Stopping process monitoring manager..."); diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs index 1f1d22b..20ad3f3 100644 --- a/Models/ApplicationSettingsModel.cs +++ b/Models/ApplicationSettingsModel.cs @@ -206,6 +206,25 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel [ObservableProperty] private List keyboardShortcuts = new(); + // Game Boost + [ObservableProperty] + private bool gameBoostEnabled = false; + + [ObservableProperty] + private GameBoostMode gameBoostMode = GameBoostMode.Automatic; + + [ObservableProperty] + private bool gameBoostAboveNormalPriority = false; + + [ObservableProperty] + private List gameBoostManualGames = new(); + + [ObservableProperty] + private List gameBoostExcludedGames = new(); + + [ObservableProperty] + private List gameBoostBackgroundProcesses = new(GameBoostProcessLists.DefaultBackgroundProcesses); + public void CopyFrom(ApplicationSettingsModel other) { if (other == null) @@ -287,6 +306,14 @@ public void CopyFrom(ApplicationSettingsModel other) this.KeyboardShortcuts = other.KeyboardShortcuts != null ? new List(other.KeyboardShortcuts) : new List(); + + // Game Boost + this.GameBoostEnabled = other.GameBoostEnabled; + this.GameBoostMode = Enum.IsDefined(other.GameBoostMode) ? other.GameBoostMode : GameBoostMode.Automatic; + this.GameBoostAboveNormalPriority = other.GameBoostAboveNormalPriority; + this.GameBoostManualGames = new List(other.GameBoostManualGames ?? new()); + this.GameBoostExcludedGames = new List(other.GameBoostExcludedGames ?? new()); + this.GameBoostBackgroundProcesses = new List(other.GameBoostBackgroundProcesses ?? new()); } // IModel implementation - properties are auto-generated by ObservableProperty diff --git a/Services/GameBoostPolicy.cs b/Services/GameBoostPolicy.cs new file mode 100644 index 0000000..562ee35 --- /dev/null +++ b/Services/GameBoostPolicy.cs @@ -0,0 +1,184 @@ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Linq; + using ThreadPilot.Models; + + public enum GameBoostMode + { + Automatic = 0, + Isolation = 1, + } + + public enum GameBoostTopologyKind + { + /// Single L3, homogeneous cores, or topology Game Boost cannot trust: no CPU Sets. + Unsupported = 0, + + /// AMD with one L3 group much larger than the others (3D V-Cache on one CCD). + AmdAsymmetricCache, + + /// AMD with two or more L3 groups of the same size (e.g. 9950X, 9950X3D2). + AmdSymmetricMultiL3, + + /// Intel with performance and efficiency cores. + IntelHybrid, + } + + public sealed record GameBoostDecision( + GameBoostTopologyKind Kind, + CpuSelection? GameSelection, + CpuSelection? BackgroundSelection) + { + public static GameBoostDecision None { get; } = new(GameBoostTopologyKind.Unsupported, null, null); + } + + /// + /// Pure topology-to-CPU-Set policy. It only ever sets process default CPU Sets: Windows still schedules + /// every thread inside them, and thread-level selections or hard affinities still take precedence. + /// + public static class GameBoostPolicy + { + // 3D V-Cache CCDs have 96 MB of L3 against 32 MB on the plain CCD; equal-size groups never qualify. + public const double VCacheMinimumRatio = 2.0; + + // Below this a game would be squeezed (2P+8E laptops, 4-core Zen 2 CCX, Zen 5c clusters). + public const int MinimumGamePhysicalCores = 6; + + public static GameBoostDecision Decide(CpuTopologySnapshot topology, string? cpuBrand, GameBoostMode mode) + { + ArgumentNullException.ThrowIfNull(topology); + + var brand = cpuBrand ?? string.Empty; + var processors = topology.LogicalProcessors + .Where(processor => !topology.ReservedLogicalProcessorIndexes.Contains(processor.GlobalIndex)) + .ToList(); + + // Every processor needs a CPU Set ID; the Environment.ProcessorCount fallback has none. + if (processors.Count == 0 + || topology.Signature.PackageCount > 1 + || processors.Any(processor => !topology.TryGetCpuSetId(processor, out _))) + { + return GameBoostDecision.None; + } + + if (brand.Contains("AMD", StringComparison.OrdinalIgnoreCase)) + { + return DecideAmd(topology, processors, mode); + } + + if (brand.Contains("Intel", StringComparison.OrdinalIgnoreCase)) + { + return DecideIntel(topology, processors); + } + + return GameBoostDecision.None; + } + + private static GameBoostDecision DecideAmd(CpuTopologySnapshot topology, List processors, GameBoostMode mode) + { + var l3ByProcessor = new Dictionary(); + foreach (var processor in processors) + { + if (!topology.TryGetLastLevelCacheIndex(processor, out var l3Index)) + { + return GameBoostDecision.None; + } + + l3ByProcessor[processor] = l3Index; + } + + var groups = processors + .GroupBy(processor => l3ByProcessor[processor]) + .OrderBy(group => group.Min(processor => processor.GlobalIndex)) + .Select(group => (L3Index: group.Key, Processors: group.ToList())) + .ToList(); + if (groups.Count < 2) + { + return GameBoostDecision.None; + } + + var sizes = groups + .Select(group => topology.TryGetLastLevelCacheSize(group.L3Index, out var size) ? size : 0UL) + .ToList(); + var largest = sizes.Max(); + var isAsymmetric = sizes.All(size => size > 0) + && sizes.Count(size => size == largest) == 1 + && sizes.Where(size => size != largest).All(size => largest >= size * VCacheMinimumRatio); + + if (isAsymmetric) + { + var cacheGroup = groups[sizes.IndexOf(largest)].Processors; + return Build( + topology, + GameBoostTopologyKind.AmdAsymmetricCache, + cacheGroup, + processors.Except(cacheGroup).ToList()); + } + + // Equal L3s: CCD0 (the group holding the lowest CPU) gets the game only in Isolation. + var firstGroup = groups[0].Processors; + var others = processors.Except(firstGroup).ToList(); + return Build( + topology, + GameBoostTopologyKind.AmdSymmetricMultiL3, + mode == GameBoostMode.Isolation ? firstGroup : null, + others, + backgroundTarget: firstGroup); + } + + private static GameBoostDecision DecideIntel(CpuTopologySnapshot topology, List processors) + { + var classes = new Dictionary(); + foreach (var processor in processors) + { + if (!topology.TryGetEfficiencyClass(processor, out var efficiencyClass)) + { + return GameBoostDecision.None; + } + + classes[processor] = efficiencyClass; + } + + var performanceClass = classes.Values.Max(); + if (classes.Values.All(value => value == performanceClass)) + { + return GameBoostDecision.None; + } + + var performanceCores = processors.Where(processor => classes[processor] == performanceClass).ToList(); + return Build( + topology, + GameBoostTopologyKind.IntelHybrid, + performanceCores, + processors.Except(performanceCores).ToList()); + } + + // backgroundTarget: the cores whose size decides whether the split is worth it when the game stays free. + private static GameBoostDecision Build( + CpuTopologySnapshot topology, + GameBoostTopologyKind kind, + List? gameProcessors, + List backgroundProcessors, + List? backgroundTarget = null) + { + var gameSide = gameProcessors ?? backgroundTarget ?? []; + if (backgroundProcessors.Count == 0 || CountPhysicalCores(topology, gameSide) < MinimumGamePhysicalCores) + { + return GameBoostDecision.None; + } + + return new GameBoostDecision( + kind, + gameProcessors == null ? null : CpuSelection.FromProcessors(gameProcessors, topology, "Game Boost: game"), + CpuSelection.FromProcessors(backgroundProcessors, topology, "Game Boost: background")); + } + + private static int CountPhysicalCores(CpuTopologySnapshot topology, IEnumerable processors) => + processors + .Select(processor => topology.TryGetCoreIndex(processor, out var coreIndex) ? coreIndex : -1 - processor.GlobalIndex) + .Distinct() + .Count(); + } +} diff --git a/Services/GameBoostProcessLists.cs b/Services/GameBoostProcessLists.cs new file mode 100644 index 0000000..74adab3 --- /dev/null +++ b/Services/GameBoostProcessLists.cs @@ -0,0 +1,109 @@ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + /// + /// Deterministic lists Game Boost decides with. Audio and voice (Discord), capture (OBS) and + /// peripheral software are deliberately in none of the built-in lists. + /// + public static class GameBoostProcessLists + { + /// Background moved off the game's cores in Automatic; the user can edit this list. + public static readonly IReadOnlyList DefaultBackgroundProcesses = + [ + // Browsers + "chrome", "msedge", "firefox", "opera", "brave", "vivaldi", + + // Sync clients + "OneDrive", "Dropbox", "GoogleDriveFS", "iCloudDrive", "MEGAsync", "nextcloud", + + // Updaters + "GoogleUpdate", "MicrosoftEdgeUpdate", "AdobeARM", + + // Launchers, once the game is running + "steam", "steamwebhelper", "EpicGamesLauncher", "EpicWebHelper", "EADesktop", "GalaxyClient", "upc", "Battle.net", + ]; + + /// Added to the user's list in Isolation: processes that are certainly secondary while gaming. + public static readonly IReadOnlyList IsolationExtraProcesses = + [ + "Widgets", "WidgetService", "PhoneExperienceHost", + "Spotify", "vlc", + "WINWORD", "EXCEL", "POWERPNT", "OUTLOOK", "olk", "ONENOTE", "Acrobat", "AcroRd32", + "Code", "devenv", "Notion", "Obsidian", + ]; + + private static readonly string[] GameLibraryPathFragments = + [ + @"\steamapps\common\", + @"\Epic Games\", + @"\XboxGames\", + @"\GOG Games\", + @"\GOG Galaxy\Games\", + @"\EA Games\", + @"\Ubisoft Game Launcher\games\", + @"\Riot Games\", + ]; + + // Executables that live in game folders but are not the game. + private static readonly string[] NonGameNameFragments = + [ + "launcher", "update", "crash", "report", "setup", "install", "unins", "redist", "prereq", + "easyanticheat", "battleye", "beservice", "vanguard", "riotclient", "helper", "overlay", + "bootstrap", "cefsharp", "service", + ]; + + public static bool IsGame( + string? processName, + string? executablePath, + IReadOnlyCollection? manualGames, + IReadOnlyCollection? excludedGames) + { + var name = Normalize(processName); + if (name.Length == 0) + { + return false; + } + + if (Matches(excludedGames, name, executablePath)) + { + return false; + } + + if (Matches(manualGames, name, executablePath)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(executablePath) + && GameLibraryPathFragments.Any(fragment => executablePath.Contains(fragment, StringComparison.OrdinalIgnoreCase)) + && !NonGameNameFragments.Any(fragment => name.Contains(fragment, StringComparison.OrdinalIgnoreCase)); + } + + public static bool IsListedBackground(string? processName, GameBoostMode mode, IReadOnlyCollection? userBackground) + { + var name = Normalize(processName); + return name.Length > 0 + && (Matches(userBackground, name, executablePath: null) + || (mode == GameBoostMode.Isolation && IsolationExtraProcesses.Any(entry => Normalize(entry).Equals(name, StringComparison.OrdinalIgnoreCase)))); + } + + /// Process name without directory and ".exe", so "C:\x\Game.exe", "Game.exe" and "game" all match. + public static string Normalize(string? processName) + { + var name = Path.GetFileName((processName ?? string.Empty).Trim()); + return name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name; + } + + // An entry with a directory matches that exact executable; a bare name matches any path. + private static bool Matches(IReadOnlyCollection? entries, string normalizedName, string? executablePath) => + entries != null && entries.Any(entry => + !string.IsNullOrWhiteSpace(entry) + && (entry.Contains('\\') + ? string.Equals(entry.Trim(), executablePath, StringComparison.OrdinalIgnoreCase) + : Normalize(entry).Equals(normalizedName, StringComparison.OrdinalIgnoreCase))); + } +} diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs new file mode 100644 index 0000000..40a9f86 --- /dev/null +++ b/Services/GameSessionService.cs @@ -0,0 +1,641 @@ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using Microsoft.Win32; + using ThreadPilot.Models; + + public enum GameBoostSetting + { + CpuSets = 0, + Priority = 1, + } + + /// + /// One change Game Boost made (or is about to make). Written before the native call and confirmed + /// after it, so a crash in between still leaves enough to undo the change. + /// + public sealed record GameBoostJournalEntry + { + public int ProcessId { get; init; } + + public long StartTimeUtcTicks { get; init; } + + public string ProcessName { get; init; } = string.Empty; + + public GameBoostSetting Setting { get; init; } + + public List AppliedCpuIndexes { get; init; } = new(); + + public ProcessPriorityClass? OriginalPriority { get; init; } + + public ProcessPriorityClass? AppliedPriority { get; init; } + + public bool Confirmed { get; init; } + } + + public interface IGameBoostProcessProbe + { + /// Start time and Windows session of a running process; false when it is gone or unreadable. + bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int sessionId); + } + + public sealed class GameBoostProcessProbe : IGameBoostProcessProbe + { + public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int sessionId) + { + try + { + using var process = Process.GetProcessById(processId); + startTimeUtcTicks = process.StartTime.ToUniversalTime().Ticks; + sessionId = process.SessionId; + return !process.HasExited; + } + catch + { + startTimeUtcTicks = 0; + sessionId = 0; + return false; + } + } + } + + public interface IGameSessionService : IDisposable + { + /// Gets the game of the running session, or null. + string? ActiveGameName { get; } + + Task GetDecisionAsync(); + + /// Undoes what a previous run left behind (crash), then starts watching for games. + Task StartAsync(); + + /// Stops watching and restores everything Game Boost changed. + Task StopAsync(); + } + + /// + /// Applies the Game Boost policy for one game session at a time: the game's and the listed background + /// processes' default CPU Sets (and optionally Above Normal priority for the game), each applied once and + /// restored when the game exits, Game Boost is turned off or ThreadPilot closes. User rules always win. + /// + public sealed class GameSessionService : IGameSessionService + { + private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(3); + + // Background is rescanned every few ticks (about 15 s), not on every one. + private const int BackgroundRescanTicks = 5; + + private static readonly JsonSerializerOptions JournalJsonOptions = new() { WriteIndented = true }; + + private readonly IApplicationSettingsService settingsService; + private readonly ICpuTopologyProvider topologyProvider; + private readonly ICpuTopologyService cpuTopologyService; + private readonly IForegroundProcessService foregroundProcessService; + private readonly IProcessService processService; + private readonly IAffinityApplyService affinityApplyService; + private readonly IPersistentProcessRuleStore ruleStore; + private readonly IPersistentProcessRuleMatcher ruleMatcher; + private readonly ProcessFilterService processFilterService; + private readonly ProcessPolicyApplyGate applyGate; + private readonly IGameBoostProcessProbe processProbe; + private readonly ILogger logger; + private readonly string journalPath; + private readonly SemaphoreSlim stateLock = new(1, 1); + private readonly HashSet<(int ProcessId, long StartTimeUtcTicks)> attempted = new(); + + private List journal = new(); + private Timer? timer; + private GameSession? session; + private GameBoostDecision? cachedDecision; + private GameBoostMode cachedDecisionMode; + private int tickCount; + private bool disposed; + + public GameSessionService( + IApplicationSettingsService settingsService, + ICpuTopologyProvider topologyProvider, + ICpuTopologyService cpuTopologyService, + IForegroundProcessService foregroundProcessService, + IProcessService processService, + IAffinityApplyService affinityApplyService, + IPersistentProcessRuleStore ruleStore, + IPersistentProcessRuleMatcher ruleMatcher, + ProcessFilterService processFilterService, + ProcessPolicyApplyGate applyGate, + IGameBoostProcessProbe processProbe, + ILogger logger, + string? journalPath = null) + { + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.topologyProvider = topologyProvider ?? throw new ArgumentNullException(nameof(topologyProvider)); + this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); + this.foregroundProcessService = foregroundProcessService ?? throw new ArgumentNullException(nameof(foregroundProcessService)); + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); + this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); + this.ruleMatcher = ruleMatcher ?? throw new ArgumentNullException(nameof(ruleMatcher)); + this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService)); + this.applyGate = applyGate ?? throw new ArgumentNullException(nameof(applyGate)); + this.processProbe = processProbe ?? throw new ArgumentNullException(nameof(processProbe)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.journalPath = journalPath ?? Path.Combine(StoragePaths.AppDataRoot, "game_boost_journal.json"); + + this.settingsService.SettingsChanged += this.OnSettingsChanged; + } + + public string? ActiveGameName => this.session?.Game.Name; + + private ApplicationSettingsModel Settings => this.settingsService.Settings; + + public async Task GetDecisionAsync() + { + var mode = this.Settings.GameBoostMode; + if (this.cachedDecision != null && this.cachedDecisionMode == mode) + { + return this.cachedDecision; + } + + try + { + var snapshot = await this.topologyProvider.GetTopologySnapshotAsync().ConfigureAwait(false); + var brand = (await this.cpuTopologyService.DetectTopologyAsync().ConfigureAwait(false)).CpuBrand; + this.cachedDecision = GameBoostPolicy.Decide(snapshot, brand, mode); + this.cachedDecisionMode = mode; + this.logger.LogInformation( + "Game Boost policy for {CpuBrand} in {Mode}: {Kind}, game CPUs {GameCpus}, background CPUs {BackgroundCpus}", + brand, + mode, + this.cachedDecision.Kind, + this.cachedDecision.GameSelection?.GlobalLogicalProcessorIndexes.Count ?? 0, + this.cachedDecision.BackgroundSelection?.GlobalLogicalProcessorIndexes.Count ?? 0); + LogAmdVCacheDriver(this.logger); + return this.cachedDecision; + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Game Boost could not read the CPU topology; it will not change CPU Sets"); + return GameBoostDecision.None; + } + } + + public async Task StartAsync() + { + await this.stateLock.WaitAsync().ConfigureAwait(false); + try + { + this.journal = await this.LoadJournalAsync().ConfigureAwait(false); + if (this.journal.Count > 0) + { + this.logger.LogInformation("Game Boost: restoring {Count} change(s) left by a previous run", this.journal.Count); + await this.RestoreAllAsync().ConfigureAwait(false); + } + + this.timer ??= new Timer(_ => TaskSafety.FireAndForget(this.TickAsync(), ex => this.logger.LogWarning(ex, "Game Boost tick failed")), null, TickInterval, TickInterval); + } + finally + { + this.stateLock.Release(); + } + } + + public async Task StopAsync() + { + this.timer?.Dispose(); + this.timer = null; + await this.stateLock.WaitAsync().ConfigureAwait(false); + try + { + await this.EndSessionAsync("ThreadPilot is closing").ConfigureAwait(false); + } + finally + { + this.stateLock.Release(); + } + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.disposed = true; + this.settingsService.SettingsChanged -= this.OnSettingsChanged; + this.timer?.Dispose(); + this.timer = null; + } + + internal async Task TickAsync() + { + if (!await this.stateLock.WaitAsync(0).ConfigureAwait(false)) + { + return; // the previous tick is still running + } + + try + { + if (!this.Settings.GameBoostEnabled) + { + await this.EndSessionAsync("Game Boost turned off").ConfigureAwait(false); + return; + } + + if (this.session == null) + { + await this.TryBeginSessionAsync().ConfigureAwait(false); + return; + } + + if (!this.IsSameProcessAlive(this.session.Game.ProcessId, this.session.GameStartTimeUtcTicks)) + { + await this.EndSessionAsync($"{this.session.Game.Name} exited").ConfigureAwait(false); + return; + } + + if (++this.tickCount % BackgroundRescanTicks == 0) + { + await this.ApplyToSessionAsync(includeGame: false).ConfigureAwait(false); + } + } + finally + { + this.stateLock.Release(); + } + } + + private async Task TryBeginSessionAsync() + { + var foregroundId = this.foregroundProcessService.TryGetForegroundProcessId(); + if (foregroundId is not int processId || processId == Environment.ProcessId) + { + return; + } + + var game = await this.processService.GetProcessByIdAsync(processId).ConfigureAwait(false); + if (game == null + || !GameBoostProcessLists.IsGame(game.Name, game.ExecutablePath, this.Settings.GameBoostManualGames, this.Settings.GameBoostExcludedGames) + || !this.processProbe.TryGetIdentity(processId, out var startTicks, out var sessionId)) + { + return; + } + + this.session = new GameSession(game, startTicks, sessionId, Path.GetDirectoryName(game.ExecutablePath)); + this.tickCount = 0; + this.logger.LogInformation("Game Boost session started for {Game} (PID {ProcessId})", game.Name, game.ProcessId); + await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); + } + + private async Task ApplyToSessionAsync(bool includeGame) + { + var currentSession = this.session; + if (currentSession == null) + { + return; + } + + var decision = await this.GetDecisionAsync().ConfigureAwait(false); + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + + // Inside the shared gate: persistent rules and Game Boost never write to processes concurrently. + using var gate = await this.applyGate.EnterAsync().ConfigureAwait(false); + + if (includeGame) + { + var game = currentSession.Game; + if (decision.GameSelection != null) + { + await this.TryApplyCpuSetsAsync(game, currentSession.GameStartTimeUtcTicks, decision.GameSelection, rules).ConfigureAwait(false); + } + + if (this.Settings.GameBoostAboveNormalPriority) + { + await this.TryApplyAboveNormalAsync(game, currentSession.GameStartTimeUtcTicks, rules).ConfigureAwait(false); + } + } + + if (decision.BackgroundSelection == null) + { + return; + } + + var processes = await this.processService.GetProcessesWithPathsAsync().ConfigureAwait(false); + foreach (var process in processes) + { + if (!this.IsBackgroundCandidate(process, currentSession) + || !this.processProbe.TryGetIdentity(process.ProcessId, out var startTicks, out var sessionId) + || sessionId == 0 + || sessionId != currentSession.GameSessionId) + { + continue; + } + + await this.TryApplyCpuSetsAsync(process, startTicks, decision.BackgroundSelection, rules).ConfigureAwait(false); + } + } + + private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSession) => + process.ProcessId != currentSession.Game.ProcessId + && process.ProcessId != Environment.ProcessId + && GameBoostProcessLists.IsListedBackground(process.Name, this.Settings.GameBoostMode, this.Settings.GameBoostBackgroundProcesses) + && !this.processFilterService.IsSystemProcess(process) + && !IsUnderDirectory(process.ExecutablePath, currentSession.GameDirectory); + + private async Task TryApplyCpuSetsAsync( + ProcessModel process, + long startTicks, + CpuSelection selection, + IReadOnlyList rules) + { + // Applied at most once per process: a later manual change is the user's and is never undone or redone. + if (!this.attempted.Add((process.ProcessId, startTicks)) + || this.HasUserRule(rules, process, GameBoostSetting.CpuSets)) + { + return; + } + + // null: unreadable, so unknown; non-empty: someone else's selection. Only an empty set is ours to change. + var current = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(process, CpuAssignmentMode.CpuSets).ConfigureAwait(false); + if (current == null || current.Count > 0) + { + return; + } + + var entry = new GameBoostJournalEntry + { + ProcessId = process.ProcessId, + StartTimeUtcTicks = startTicks, + ProcessName = process.Name, + Setting = GameBoostSetting.CpuSets, + AppliedCpuIndexes = selection.GlobalLogicalProcessorIndexes.ToList(), + }; + await this.AddJournalEntryAsync(entry).ConfigureAwait(false); + + var result = await this.affinityApplyService.ApplyAsync(process, selection, CpuAssignmentMode.CpuSets).ConfigureAwait(false); + if (result.Success) + { + await this.ConfirmJournalEntryAsync(entry).ConfigureAwait(false); + this.logger.LogInformation( + "Game Boost set CPU Sets of {ProcessName} (PID {ProcessId}) to {CpuCount} CPUs", + process.Name, + process.ProcessId, + entry.AppliedCpuIndexes.Count); + } + else + { + // Left in the journal unconfirmed: if the call half-succeeded, the restore still finds and undoes it. + this.logger.LogInformation( + "Game Boost skipped {ProcessName} (PID {ProcessId}): {Reason}", + process.Name, + process.ProcessId, + result.TechnicalMessage); + } + } + + private async Task TryApplyAboveNormalAsync(ProcessModel game, long startTicks, IReadOnlyList rules) + { + if (game.Priority != ProcessPriorityClass.Normal + || this.HasUserRule(rules, game, GameBoostSetting.Priority) + || this.journal.Any(e => e.ProcessId == game.ProcessId && e.StartTimeUtcTicks == startTicks && e.Setting == GameBoostSetting.Priority)) + { + return; + } + + var entry = new GameBoostJournalEntry + { + ProcessId = game.ProcessId, + StartTimeUtcTicks = startTicks, + ProcessName = game.Name, + Setting = GameBoostSetting.Priority, + OriginalPriority = ProcessPriorityClass.Normal, + AppliedPriority = ProcessPriorityClass.AboveNormal, + }; + await this.AddJournalEntryAsync(entry).ConfigureAwait(false); + + try + { + await this.processService.SetProcessPriority(game, ProcessPriorityClass.AboveNormal, ProcessPriorityWriteSource.GameBoost).ConfigureAwait(false); + await this.ConfirmJournalEntryAsync(entry).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogInformation(ex, "Game Boost could not raise the priority of {Game}", game.Name); + } + } + + private async Task EndSessionAsync(string reason) + { + if (this.session == null && this.journal.Count == 0) + { + return; + } + + if (this.session != null) + { + this.logger.LogInformation("Game Boost session for {Game} ended: {Reason}", this.session.Game.Name, reason); + } + + this.session = null; + this.attempted.Clear(); + await this.RestoreAllAsync().ConfigureAwait(false); + } + + private async Task RestoreAllAsync() + { + IReadOnlyList rules; + try + { + rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Game Boost could not read persistent rules before restoring; restoring anyway"); + rules = []; + } + + using var gate = await this.applyGate.EnterAsync().ConfigureAwait(false); + foreach (var entry in this.journal.ToList()) + { + try + { + await this.RestoreAsync(entry, rules).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Game Boost could not restore {Setting} of {ProcessName} (PID {ProcessId})", entry.Setting, entry.ProcessName, entry.ProcessId); + } + + this.journal.Remove(entry); + } + + await this.SaveJournalAsync().ConfigureAwait(false); + } + + // Restores only a value that is still exactly what Game Boost set, on the same process instance, + // and that no user rule has claimed since. + private async Task RestoreAsync(GameBoostJournalEntry entry, IReadOnlyList rules) + { + if (!this.IsSameProcessAlive(entry.ProcessId, entry.StartTimeUtcTicks)) + { + return; + } + + var process = await this.processService.GetProcessByIdAsync(entry.ProcessId).ConfigureAwait(false); + if (process == null || this.HasUserRule(rules, process, entry.Setting)) + { + return; + } + + if (entry.Setting == GameBoostSetting.CpuSets) + { + var current = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(process, CpuAssignmentMode.CpuSets).ConfigureAwait(false); + if (current != null && current.SequenceEqual(entry.AppliedCpuIndexes.OrderBy(index => index))) + { + await this.processService.ClearProcessCpuSetAsync(process).ConfigureAwait(false); + this.logger.LogInformation("Game Boost restored CPU Sets of {ProcessName} (PID {ProcessId})", process.Name, process.ProcessId); + } + + return; + } + + if (entry.OriginalPriority is ProcessPriorityClass original && process.Priority == entry.AppliedPriority) + { + await this.processService.SetProcessPriority(process, original, ProcessPriorityWriteSource.CleanupOrRestore).ConfigureAwait(false); + } + } + + private bool HasUserRule(IReadOnlyList rules, ProcessModel process, GameBoostSetting setting) => + rules.Any(rule => this.ruleMatcher.IsMatch(rule, process) && (setting == GameBoostSetting.CpuSets + ? rule.CpuSelection != null || rule.LegacyAffinityMask.HasValue + : rule.Priority.HasValue)); + + private bool IsSameProcessAlive(int processId, long startTicks) => + this.processProbe.TryGetIdentity(processId, out var currentStartTicks, out _) && currentStartTicks == startTicks; + + private static bool IsUnderDirectory(string? path, string? directory) => + !string.IsNullOrWhiteSpace(path) + && !string.IsNullOrWhiteSpace(directory) + && path.StartsWith(directory.TrimEnd('\\') + "\\", StringComparison.OrdinalIgnoreCase); + + private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) + { + var oldSettings = e.OldSettings; + var newSettings = e.NewSettings; + var changed = oldSettings.GameBoostEnabled != newSettings.GameBoostEnabled + || oldSettings.GameBoostMode != newSettings.GameBoostMode + || oldSettings.GameBoostAboveNormalPriority != newSettings.GameBoostAboveNormalPriority + || !oldSettings.GameBoostBackgroundProcesses.SequenceEqual(newSettings.GameBoostBackgroundProcesses) + || !oldSettings.GameBoostManualGames.SequenceEqual(newSettings.GameBoostManualGames) + || !oldSettings.GameBoostExcludedGames.SequenceEqual(newSettings.GameBoostExcludedGames); + if (changed) + { + TaskSafety.FireAndForget(this.ReapplyAsync(), ex => this.logger.LogWarning(ex, "Game Boost could not apply the new settings")); + } + } + + // A settings change restores everything first, then applies the new policy to the same game, if still running. + internal async Task ReapplyAsync() + { + await this.stateLock.WaitAsync().ConfigureAwait(false); + try + { + var previous = this.session; + await this.EndSessionAsync("Game Boost settings changed").ConfigureAwait(false); + if (previous == null + || !this.Settings.GameBoostEnabled + || !this.IsSameProcessAlive(previous.Game.ProcessId, previous.GameStartTimeUtcTicks) + || !GameBoostProcessLists.IsGame(previous.Game.Name, previous.Game.ExecutablePath, this.Settings.GameBoostManualGames, this.Settings.GameBoostExcludedGames)) + { + return; + } + + this.session = previous; + this.tickCount = 0; + await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); + } + finally + { + this.stateLock.Release(); + } + } + + private async Task AddJournalEntryAsync(GameBoostJournalEntry entry) + { + this.journal.Add(entry); + await this.SaveJournalAsync().ConfigureAwait(false); + } + + private async Task ConfirmJournalEntryAsync(GameBoostJournalEntry entry) + { + var index = this.journal.IndexOf(entry); + if (index >= 0) + { + this.journal[index] = entry with { Confirmed = true }; + await this.SaveJournalAsync().ConfigureAwait(false); + } + } + + private async Task> LoadJournalAsync() + { + try + { + if (!File.Exists(this.journalPath)) + { + return new(); + } + + var json = await File.ReadAllTextAsync(this.journalPath).ConfigureAwait(false); + return JsonSerializer.Deserialize>(json) ?? new(); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Game Boost journal at {Path} is unreadable; nothing to restore", this.journalPath); + return new(); + } + } + + private async Task SaveJournalAsync() + { + try + { + if (this.journal.Count == 0) + { + File.Delete(this.journalPath); + return; + } + + await AtomicFileWriter.WriteAllTextAsync(this.journalPath, JsonSerializer.Serialize(this.journal, JournalJsonOptions)).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Game Boost could not write its journal at {Path}", this.journalPath); + } + } + + private static void LogAmdVCacheDriver(ILogger logger) + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Services\amd3dvcache"); + if (key != null) + { + logger.LogInformation("AMD 3D V-Cache Performance Optimizer driver is installed; it may also park cores for games"); + } + } + catch + { + // Logging only. + } + } + + private sealed record GameSession(ProcessModel Game, long GameStartTimeUtcTicks, int GameSessionId, string? GameDirectory); + } +} diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index 073b408..f275ea3 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -33,6 +33,7 @@ public sealed class PersistentRulesEngine : IPersistentRulesEngine private readonly IProcessService processService; private readonly IProcessMemoryPriorityService memoryPriorityService; private readonly ProcessPowerRequestService? powerRequestService; + private readonly ProcessPolicyApplyGate? applyGate; private readonly ILogger logger; public PersistentRulesEngine( @@ -42,7 +43,8 @@ public PersistentRulesEngine( IProcessService processService, IProcessMemoryPriorityService memoryPriorityService, ILogger logger, - ProcessPowerRequestService? powerRequestService = null) + ProcessPowerRequestService? powerRequestService = null, + ProcessPolicyApplyGate? applyGate = null) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); @@ -51,6 +53,7 @@ public PersistentRulesEngine( this.memoryPriorityService = memoryPriorityService ?? throw new ArgumentNullException(nameof(memoryPriorityService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.powerRequestService = powerRequestService; + this.applyGate = applyGate; } public async Task> ApplyMatchingRulesAsync( @@ -63,6 +66,7 @@ public async Task> ApplyMatchingRulesAs var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); var results = new List(); + using var gate = this.applyGate == null ? null : await this.applyGate.EnterAsync(cancellationToken).ConfigureAwait(false); foreach (var rule in PersistentRuleResolution.MatchInApplyOrder(rules, process, this.matcher) .Where(rule => ruleFilter == null || ruleFilter(rule))) { diff --git a/Services/ProcessPolicyApplyGate.cs b/Services/ProcessPolicyApplyGate.cs new file mode 100644 index 0000000..cb7b5f1 --- /dev/null +++ b/Services/ProcessPolicyApplyGate.cs @@ -0,0 +1,34 @@ +namespace ThreadPilot.Services +{ + using System; + using System.Threading; + using System.Threading.Tasks; + + /// + /// One gate for everything that applies policies to running processes on its own (persistent rules, + /// Game Boost), so the two never write CPU Sets or priorities to the same process at the same time. + /// + public sealed class ProcessPolicyApplyGate + { + private readonly SemaphoreSlim semaphore = new(1, 1); + + public async Task EnterAsync(CancellationToken cancellationToken = default) + { + await this.semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new Releaser(this.semaphore); + } + + private sealed class Releaser(SemaphoreSlim semaphore) : IDisposable + { + private int released; + + public void Dispose() + { + if (Interlocked.Exchange(ref this.released, 1) == 0) + { + semaphore.Release(); + } + } + } + } +} diff --git a/Services/ProcessPriorityWriteSource.cs b/Services/ProcessPriorityWriteSource.cs index 3982753..542f586 100644 --- a/Services/ProcessPriorityWriteSource.cs +++ b/Services/ProcessPriorityWriteSource.cs @@ -9,5 +9,6 @@ public enum ProcessPriorityWriteSource ProcessProfileLoad, ManualUiAction, CleanupOrRestore, + GameBoost, } } diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index a129940..39e734b 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -78,7 +78,10 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Tests/ThreadPilot.Core.Tests/GameBoostPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/GameBoostPolicyTests.cs new file mode 100644 index 0000000..fdb4fe4 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/GameBoostPolicyTests.cs @@ -0,0 +1,179 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class GameBoostPolicyTests + { + private const ulong MB = 1024UL * 1024UL; + + [Theory] + [InlineData(GameBoostMode.Automatic)] + [InlineData(GameBoostMode.Isolation)] + public void Ryzen7950X3D_GameOnVCacheCcd_BackgroundOnOther(GameBoostMode mode) + { + var topology = TopologyFixtures.Amd([8, 8], [96 * MB, 32 * MB]); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 7950X3D 16-Core Processor", mode); + + Assert.Equal(GameBoostTopologyKind.AmdAsymmetricCache, decision.Kind); + Assert.Equal(Enumerable.Range(0, 16), decision.GameSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal(Enumerable.Range(16, 16), decision.BackgroundSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal(16, decision.GameSelection.CpuSetIds.Count); + } + + [Fact] + public void Ryzen7900X3D_SixPlusSix_UsesRealL3Groups() + { + var topology = TopologyFixtures.Amd([6, 6], [96 * MB, 32 * MB]); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 7900X3D", GameBoostMode.Automatic); + + Assert.Equal(Enumerable.Range(0, 12), decision.GameSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal(Enumerable.Range(12, 12), decision.BackgroundSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public void VCacheOnSecondCcd_FollowsTheCacheNotTheIndex() + { + var topology = TopologyFixtures.Amd([8, 8], [32 * MB, 96 * MB]); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 9950X3D", GameBoostMode.Automatic); + + Assert.Equal(Enumerable.Range(16, 16), decision.GameSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public void Ryzen9950X_Automatic_MovesOnlyBackground() + { + var topology = TopologyFixtures.Amd([8, 8], [32 * MB, 32 * MB]); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 9950X", GameBoostMode.Automatic); + + Assert.Equal(GameBoostTopologyKind.AmdSymmetricMultiL3, decision.Kind); + Assert.Null(decision.GameSelection); + Assert.Equal(Enumerable.Range(16, 16), decision.BackgroundSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public void Ryzen9950X_Isolation_GameOnCcd0() + { + var topology = TopologyFixtures.Amd([8, 8], [32 * MB, 32 * MB]); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 9950X", GameBoostMode.Isolation); + + Assert.Equal(Enumerable.Range(0, 16), decision.GameSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal(Enumerable.Range(16, 16), decision.BackgroundSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public void Ryzen9950X3D2_EqualVCache_IsSymmetric() + { + var topology = TopologyFixtures.Amd([8, 8], [96 * MB, 96 * MB]); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 9950X3D2", GameBoostMode.Automatic); + + Assert.Equal(GameBoostTopologyKind.AmdSymmetricMultiL3, decision.Kind); + Assert.Null(decision.GameSelection); + } + + [Fact] + public void Ryzen5800X3D_SingleL3_DoesNothing() + { + var topology = TopologyFixtures.Amd([8], [96 * MB]); + + Assert.Equal(GameBoostDecision.None, GameBoostPolicy.Decide(topology, "AMD Ryzen 7 5800X3D", GameBoostMode.Isolation)); + } + + [Fact] + public void Zen2FourCoreCcx_TooSmallForTheGame_DoesNothing() + { + var topology = TopologyFixtures.Amd([4, 4, 4, 4], [16 * MB, 16 * MB, 16 * MB, 16 * MB]); + + Assert.Equal(GameBoostDecision.None, GameBoostPolicy.Decide(topology, "AMD Ryzen 9 3950X", GameBoostMode.Isolation)); + } + + [Fact] + public void Amd_MissingCacheSizes_IsNeverTreatedAsVCache() + { + var topology = TopologyFixtures.Amd([8, 8], cacheSizes: null); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 7950X3D", GameBoostMode.Automatic); + + Assert.Equal(GameBoostTopologyKind.AmdSymmetricMultiL3, decision.Kind); + Assert.Null(decision.GameSelection); + } + + [Fact] + public void Amd_MissingL3Index_DoesNothing() + { + var topology = TopologyFixtures.Amd([8, 8], [96 * MB, 32 * MB], withL3Indexes: false); + + Assert.Equal(GameBoostDecision.None, GameBoostPolicy.Decide(topology, "AMD Ryzen 9 7950X3D", GameBoostMode.Automatic)); + } + + [Fact] + public void Intel14700K_GameOnPCores_BackgroundOnECores() + { + var topology = TopologyFixtures.IntelHybrid(performanceCores: 8, efficiencyCores: 12); + + var decision = GameBoostPolicy.Decide(topology, "Intel(R) Core(TM) i7-14700K", GameBoostMode.Automatic); + + Assert.Equal(GameBoostTopologyKind.IntelHybrid, decision.Kind); + Assert.Equal(Enumerable.Range(0, 16), decision.GameSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal(Enumerable.Range(16, 12), decision.BackgroundSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public void Intel_DoesNotNeedL3Indexes() + { + var topology = TopologyFixtures.IntelHybrid(performanceCores: 8, efficiencyCores: 16, withL3Indexes: false); + + var decision = GameBoostPolicy.Decide(topology, "Intel(R) Core(TM) i9-14900K", GameBoostMode.Automatic); + + Assert.Equal(GameBoostTopologyKind.IntelHybrid, decision.Kind); + } + + [Fact] + public void IntelLaptopTwoPCores_TooSmallForTheGame_DoesNothing() + { + var topology = TopologyFixtures.IntelHybrid(performanceCores: 2, efficiencyCores: 8); + + Assert.Equal(GameBoostDecision.None, GameBoostPolicy.Decide(topology, "Intel(R) Core(TM) i5-1235U", GameBoostMode.Automatic)); + } + + [Fact] + public void IntelWithoutEfficiencyClasses_DoesNothing() + { + var topology = TopologyFixtures.Amd([8], [36 * MB]); + + Assert.Equal(GameBoostDecision.None, GameBoostPolicy.Decide(topology, "Intel(R) Core(TM) i7-9700K", GameBoostMode.Isolation)); + } + + [Fact] + public void FallbackTopologyWithoutCpuSetIds_DoesNothing() + { + var topology = TopologyFixtures.Amd([8, 8], [96 * MB, 32 * MB], withCpuSetIds: false); + + Assert.Equal(GameBoostDecision.None, GameBoostPolicy.Decide(topology, "AMD Ryzen 9 7950X3D", GameBoostMode.Automatic)); + } + + [Fact] + public void UnknownVendor_DoesNothing() + { + var topology = TopologyFixtures.Amd([8, 8], [96 * MB, 32 * MB]); + + Assert.Equal(GameBoostDecision.None, GameBoostPolicy.Decide(topology, null, GameBoostMode.Automatic)); + } + + [Fact] + public void ReservedProcessors_AreNeverSelected() + { + var topology = TopologyFixtures.Amd([8, 8], [96 * MB, 32 * MB], reserved: [31]); + + var decision = GameBoostPolicy.Decide(topology, "AMD Ryzen 9 7950X3D", GameBoostMode.Automatic); + + Assert.DoesNotContain(31, decision.BackgroundSelection!.GlobalLogicalProcessorIndexes); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/GameBoostProcessListsTests.cs b/Tests/ThreadPilot.Core.Tests/GameBoostProcessListsTests.cs new file mode 100644 index 0000000..c88be49 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/GameBoostProcessListsTests.cs @@ -0,0 +1,56 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class GameBoostProcessListsTests + { + [Theory] + [InlineData("Cyberpunk2077", @"D:\SteamLibrary\steamapps\common\Cyberpunk 2077\bin\x64\Cyberpunk2077.exe", true)] + [InlineData("FortniteClient-Win64-Shipping", @"C:\Program Files\Epic Games\Fortnite\FortniteGame\Binaries\Win64\FortniteClient-Win64-Shipping.exe", true)] + [InlineData("Forza", @"C:\XboxGames\Forza Horizon 5\Content\ForzaHorizon5.exe", true)] + [InlineData("UnityCrashHandler64", @"D:\SteamLibrary\steamapps\common\Game\UnityCrashHandler64.exe", false)] + [InlineData("EasyAntiCheat_EOS_Setup", @"D:\SteamLibrary\steamapps\common\Game\EasyAntiCheat\EasyAntiCheat_EOS_Setup.exe", false)] + [InlineData("RiotClientServices", @"C:\Riot Games\Riot Client\RiotClientServices.exe", false)] + [InlineData("GameLauncher", @"D:\SteamLibrary\steamapps\common\Game\GameLauncher.exe", false)] + [InlineData("steam", @"C:\Program Files (x86)\Steam\steam.exe", false)] + [InlineData("chrome", @"C:\Program Files\Google\Chrome\Application\chrome.exe", false)] + public void IsGame_FromLibraryPath(string name, string path, bool expected) => + Assert.Equal(expected, GameBoostProcessLists.IsGame(name, path, manualGames: null, excludedGames: null)); + + [Fact] + public void IsGame_ManualEntryByNameOrPath() + { + const string path = @"C:\Program Files (x86)\World of Warcraft\_retail_\Wow.exe"; + + Assert.True(GameBoostProcessLists.IsGame("Wow", path, ["wow.exe"], null)); + Assert.True(GameBoostProcessLists.IsGame("Wow", path, [path], null)); + Assert.False(GameBoostProcessLists.IsGame("Wow", path, [@"D:\Other\Wow.exe"], null)); + } + + [Fact] + public void IsGame_ExclusionWinsOverLibraryAndManual() + { + const string path = @"D:\SteamLibrary\steamapps\common\Game\Game.exe"; + + Assert.False(GameBoostProcessLists.IsGame("Game", path, ["Game"], ["Game.exe"])); + } + + [Fact] + public void IsListedBackground_IsolationAddsOnlyTheExtraList() + { + var userList = GameBoostProcessLists.DefaultBackgroundProcesses.ToList(); + + Assert.True(GameBoostProcessLists.IsListedBackground("chrome", GameBoostMode.Automatic, userList)); + Assert.False(GameBoostProcessLists.IsListedBackground("Spotify", GameBoostMode.Automatic, userList)); + Assert.True(GameBoostProcessLists.IsListedBackground("Spotify", GameBoostMode.Isolation, userList)); + Assert.False(GameBoostProcessLists.IsListedBackground("Discord", GameBoostMode.Isolation, userList)); + Assert.False(GameBoostProcessLists.IsListedBackground("obs64", GameBoostMode.Isolation, userList)); + } + + [Fact] + public void IsListedBackground_UserAddedEntry() + { + Assert.True(GameBoostProcessLists.IsListedBackground("MyTool", GameBoostMode.Automatic, ["MyTool.exe"])); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs new file mode 100644 index 0000000..57b5566 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -0,0 +1,376 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class GameSessionServiceTests : IDisposable + { + private const int GamePid = 100; + private const int ChromePid = 200; + private const int DiscordPid = 300; + private const ulong MB = 1024UL * 1024UL; + + private static readonly int[] VCacheCpus = Enumerable.Range(0, 16).ToArray(); + private static readonly int[] OtherCcdCpus = Enumerable.Range(16, 16).ToArray(); + + private readonly string journalPath = Path.Combine(Path.GetTempPath(), $"tp-gameboost-{Guid.NewGuid():N}.json"); + private readonly ApplicationSettingsModel settings = new() { GameBoostEnabled = true }; + private readonly Mock settingsService = new(); + private readonly FakeProcesses processes = new(); + private readonly List rules = new(); + private int? foregroundPid = GamePid; + + public GameSessionServiceTests() + { + this.settingsService.SetupGet(s => s.Settings).Returns(() => this.settings); + this.processes.Add(GamePid, "Game", @"D:\SteamLibrary\steamapps\common\Game\Game.exe"); + this.processes.Add(ChromePid, "chrome", @"C:\Program Files\Google\Chrome\Application\chrome.exe"); + this.processes.Add(DiscordPid, "Discord", @"C:\Users\u\AppData\Local\Discord\app-1\Discord.exe"); + } + + public void Dispose() => File.Delete(this.journalPath); + + [Fact] + public async Task GameInLibrary_GameOnVCache_ListedBackgroundOnOtherCcd() + { + using var service = this.CreateService(); + + await service.TickAsync(); + + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[ChromePid]); + Assert.Empty(this.processes.CpuSets[DiscordPid]); + Assert.Equal("Game", service.ActiveGameName); + Assert.True(File.Exists(this.journalPath)); + } + + [Fact] + public async Task NotAGame_NoSession() + { + this.foregroundPid = ChromePid; + using var service = this.CreateService(); + + await service.TickAsync(); + + Assert.Null(service.ActiveGameName); + Assert.All(this.processes.CpuSets.Values, Assert.Empty); + } + + [Fact] + public async Task GameExit_RestoresBackground_AndDeletesJournal() + { + using var service = this.CreateService(); + await service.TickAsync(); + + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Empty(this.processes.CpuSets[ChromePid]); + Assert.Null(service.ActiveGameName); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task AltTab_KeepsTheSession() + { + using var service = this.CreateService(); + await service.TickAsync(); + + this.foregroundPid = ChromePid; + await service.TickAsync(); + + Assert.Equal("Game", service.ActiveGameName); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + } + + [Fact] + public async Task ExistingCpuSet_IsSomeoneElsesChoice_NotTouched() + { + this.processes.CpuSets[ChromePid] = [2, 3]; + using var service = this.CreateService(); + + await service.TickAsync(); + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Equal([2, 3], this.processes.CpuSets[ChromePid]); + } + + [Fact] + public async Task UnreadableCpuSet_NotTouched() + { + this.processes.Unreadable.Add(ChromePid); + using var service = this.CreateService(); + + await service.TickAsync(); + + Assert.DoesNotContain(ChromePid, this.processes.ApplyCalls); + } + + [Fact] + public async Task UserRuleWithCores_WinsOverGameBoost() + { + this.rules.Add(new PersistentProcessRule { IsEnabled = true, ProcessName = "Game", LegacyAffinityMask = 0xF }); + using var service = this.CreateService(); + + await service.TickAsync(); + + Assert.Empty(this.processes.CpuSets[GamePid]); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[ChromePid]); + } + + [Fact] + public async Task ManualChangeDuringSession_IsNotUndone() + { + using var service = this.CreateService(); + await service.TickAsync(); + + this.processes.CpuSets[ChromePid] = [4, 5]; + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Equal([4, 5], this.processes.CpuSets[ChromePid]); + } + + [Fact] + public async Task RuleSavedDuringSession_IsNotUndone() + { + using var service = this.CreateService(); + await service.TickAsync(); + + this.rules.Add(new PersistentProcessRule { IsEnabled = true, ProcessName = "chrome", LegacyAffinityMask = 0xFFFF0000 }); + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[ChromePid]); + } + + [Fact] + public async Task TurnedOff_RestoresImmediately() + { + using var service = this.CreateService(); + await service.TickAsync(); + + this.settings.GameBoostEnabled = false; + await service.ReapplyAsync(); + + Assert.Empty(this.processes.CpuSets[GamePid]); + Assert.Empty(this.processes.CpuSets[ChromePid]); + Assert.Null(service.ActiveGameName); + } + + [Fact] + public async Task ModeChange_RestoresThenReappliesToTheSameGame() + { + using var service = this.CreateService(); + await service.TickAsync(); + + this.settings.GameBoostMode = GameBoostMode.Isolation; + this.processes.Add(400, "Spotify", @"C:\Users\u\AppData\Roaming\Spotify\Spotify.exe"); + await service.ReapplyAsync(); + + Assert.Equal("Game", service.ActiveGameName); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[400]); + } + + [Fact] + public async Task CrashRecovery_RestoresSameProcess_SkipsReusedPid() + { + using (var crashed = this.CreateService()) + { + await crashed.TickAsync(); + } + + this.processes.Restart(GamePid); // same PID, new process instance + using var service = this.CreateService(); + await service.StartAsync(); + + Assert.Empty(this.processes.CpuSets[ChromePid]); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); // not ours anymore, left alone + Assert.False(File.Exists(this.journalPath)); + await service.StopAsync(); + } + + [Fact] + public async Task FailedApply_StaysInJournal_AndIsUndoneIfItHalfSucceeded() + { + this.processes.FailButApply.Add(ChromePid); + using var service = this.CreateService(); + await service.TickAsync(); + + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Empty(this.processes.CpuSets[ChromePid]); + } + + [Fact] + public async Task AboveNormal_AppliedToGame_RestoredOnlyIfStillOurs() + { + this.settings.GameBoostAboveNormalPriority = true; + using var service = this.CreateService(); + await service.TickAsync(); + Assert.Equal(ProcessPriorityClass.AboveNormal, this.processes.Priorities[GamePid]); + + this.processes.Exit(ChromePid); + await service.ReapplyAsync(); // settings change: restore, then apply again + Assert.Equal(ProcessPriorityClass.AboveNormal, this.processes.Priorities[GamePid]); + + this.processes.Priorities[GamePid] = ProcessPriorityClass.High; // user change + this.settings.GameBoostEnabled = false; + await service.ReapplyAsync(); + Assert.Equal(ProcessPriorityClass.High, this.processes.Priorities[GamePid]); + } + + [Fact] + public async Task AboveNormalOff_PriorityUntouched() + { + using var service = this.CreateService(); + + await service.TickAsync(); + + Assert.Equal(ProcessPriorityClass.Normal, this.processes.Priorities[GamePid]); + } + + [Fact] + public async Task UnsupportedCpu_NoCpuSets() + { + using var service = this.CreateService(TopologyFixtures.Amd([8], [96 * MB])); + + await service.TickAsync(); + + Assert.Equal("Game", service.ActiveGameName); + Assert.Empty(this.processes.ApplyCalls); + } + + [Fact] + public async Task SecondGame_IgnoredWhileASessionRuns() + { + this.processes.Add(500, "Other", @"D:\SteamLibrary\steamapps\common\Other\Other.exe"); + using var service = this.CreateService(); + await service.TickAsync(); + + this.foregroundPid = 500; + await service.TickAsync(); + + Assert.Equal("Game", service.ActiveGameName); + Assert.Empty(this.processes.CpuSets[500]); + } + + private GameSessionService CreateService(CpuTopologySnapshot? topology = null) + { + var snapshot = topology ?? TopologyFixtures.Amd([8, 8], [96 * MB, 32 * MB]); + var topologyProvider = new Mock(); + topologyProvider.Setup(p => p.GetTopologySnapshotAsync(It.IsAny())).ReturnsAsync(snapshot); + var cpuTopologyService = new Mock(); + cpuTopologyService.Setup(s => s.DetectTopologyAsync()).ReturnsAsync(new CpuTopologyModel { CpuBrand = "AMD Ryzen 9 7950X3D" }); + var foreground = new Mock(); + foreground.Setup(f => f.TryGetForegroundProcessId()).Returns(() => this.foregroundPid); + var ruleStore = new Mock(); + ruleStore.Setup(s => s.LoadAsync()).ReturnsAsync(() => this.rules.ToList()); + + return new GameSessionService( + this.settingsService.Object, + topologyProvider.Object, + cpuTopologyService.Object, + foreground.Object, + this.processes.ProcessService, + this.processes.AffinityService, + ruleStore.Object, + new PersistentProcessRuleMatcher(), + new ProcessFilterService(), + new ProcessPolicyApplyGate(), + this.processes, + NullLogger.Instance, + this.journalPath); + } + + /// In-memory processes: CPU Sets, priorities and identities the service reads and writes. + private sealed class FakeProcesses : IGameBoostProcessProbe + { + private readonly Dictionary models = new(); + private readonly Dictionary startTimes = new(); + + public FakeProcesses() + { + var processService = new Mock(); + processService.Setup(s => s.GetProcessByIdAsync(It.IsAny())) + .ReturnsAsync((int pid) => this.startTimes.ContainsKey(pid) ? this.Snapshot(pid) : null); + processService.Setup(s => s.GetProcessesWithPathsAsync()) + .ReturnsAsync(() => this.startTimes.Keys.Select(this.Snapshot).ToList()); + processService.Setup(s => s.GetCpuAssignmentLogicalProcessorIndexesAsync(It.IsAny(), CpuAssignmentMode.CpuSets)) + .ReturnsAsync((ProcessModel p, CpuAssignmentMode _) => this.Unreadable.Contains(p.ProcessId) ? null : this.CpuSets[p.ProcessId].ToList()); + processService.Setup(s => s.ClearProcessCpuSetAsync(It.IsAny())) + .ReturnsAsync((ProcessModel p) => + { + this.CpuSets[p.ProcessId] = []; + return true; + }); + processService.Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((ProcessModel p, ProcessPriorityClass priority, ProcessPriorityWriteSource _) => this.Priorities[p.ProcessId] = priority) + .Returns(Task.CompletedTask); + this.ProcessService = processService.Object; + + var affinityService = new Mock(); + affinityService.Setup(s => s.ApplyAsync(It.IsAny(), It.IsAny(), CpuAssignmentMode.CpuSets)) + .ReturnsAsync((ProcessModel p, CpuSelection selection, CpuAssignmentMode _) => + { + this.ApplyCalls.Add(p.ProcessId); + this.CpuSets[p.ProcessId] = selection.GlobalLogicalProcessorIndexes.ToList(); + return this.FailButApply.Contains(p.ProcessId) + ? AffinityApplyResult.Failed("NativeApplyFailed", "failed", "verification mismatch") + : AffinityApplyResult.SucceededWithCpuSets("ok"); + }); + this.AffinityService = affinityService.Object; + } + + public IProcessService ProcessService { get; } + + public IAffinityApplyService AffinityService { get; } + + public Dictionary> CpuSets { get; } = new(); + + public Dictionary Priorities { get; } = new(); + + public HashSet Unreadable { get; } = new(); + + public HashSet FailButApply { get; } = new(); + + public List ApplyCalls { get; } = new(); + + public void Add(int pid, string name, string path) + { + this.models[pid] = new ProcessModel { ProcessId = pid, Name = name, ExecutablePath = path }; + this.startTimes[pid] = 1000 + pid; + this.CpuSets[pid] = []; + this.Priorities[pid] = ProcessPriorityClass.Normal; + } + + public void Exit(int pid) => this.startTimes.Remove(pid); + + public void Restart(int pid) => this.startTimes[pid] += 1; + + public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int sessionId) + { + sessionId = 1; + return this.startTimes.TryGetValue(processId, out startTimeUtcTicks); + } + + private ProcessModel Snapshot(int pid) + { + var model = this.models[pid]; + return new ProcessModel + { + ProcessId = pid, + Name = model.Name, + ExecutablePath = model.ExecutablePath, + Priority = this.Priorities[pid], + }; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index d925878..774d9bd 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -495,6 +495,32 @@ public async Task PriorityWriteSource_IsRecordedForPersistentRule() processService.Verify(s => s.SetProcessPriority(process, ProcessPriorityClass.High, ProcessPriorityWriteSource.PersistentRuleInitialApply), Times.Once); } + [Fact] + public async Task ApplyMatchingRulesAsync_WaitsForTheSharedApplyGate() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = CreateAffinityService(); + var gate = new ProcessPolicyApplyGate(); + var engine = new PersistentRulesEngine( + new FakePersistentProcessRuleStore([rule]), + new PersistentProcessRuleMatcher(), + affinity.Object, + CreateProcessService().Object, + CreateMemoryPriorityService().Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + applyGate: gate); + var process = CreateProcess(); + + var held = await gate.EnterAsync(); + var apply = engine.ApplyMatchingRulesAsync(process); + await Task.Delay(50); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + + held.Dispose(); + await apply; + affinity.Verify(s => s.ApplyAsync(process, 3), Times.Once); + } + private static PersistentRulesEngine CreateEngine( IReadOnlyList rules, IAffinityApplyService affinityApplyService, diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksGameBoostTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksGameBoostTests.cs new file mode 100644 index 0000000..aefb045 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksGameBoostTests.cs @@ -0,0 +1,94 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Xml.Linq; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class SystemTweaksGameBoostTests + { + [Fact] + public async Task Load_ShowsSettingsAndDecision_ToggleSavesAtOnce() + { + var settings = new ApplicationSettingsModel { GameBoostMode = GameBoostMode.Isolation, GameBoostManualGames = ["Wow"] }; + ApplicationSettingsModel? saved = null; + var settingsService = new Mock(); + settingsService.SetupGet(s => s.Settings).Returns(settings); + settingsService.Setup(s => s.UpdateSettingsAsync(It.IsAny())) + .Callback((ApplicationSettingsModel model) => saved = model) + .Returns(Task.CompletedTask); + var session = new Mock(); + session.Setup(s => s.GetDecisionAsync()).ReturnsAsync(GameBoostDecision.None); + var viewModel = new SystemTweaksViewModel( + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance, + settingsService: settingsService.Object, + gameSessionService: session.Object); + + await viewModel.LoadGameBoostAsync(); + + Assert.True(viewModel.IsGameBoostAvailable); + Assert.True(viewModel.GameBoostIsolation); + Assert.False(viewModel.GameBoostAutomatic); + Assert.Equal("Wow", viewModel.GameBoostManualGamesText); + Assert.Contains("Windows decides", viewModel.GameBoostDecisionText, StringComparison.Ordinal); + Assert.Null(saved); // loading never saves + + viewModel.GameBoostEnabled = true; + await Task.Delay(50); + + Assert.NotNull(saved); + Assert.True(saved!.GameBoostEnabled); + Assert.Equal(GameBoostMode.Isolation, saved.GameBoostMode); + Assert.Equal(["Wow"], saved.GameBoostManualGames); + } + + [Fact] + public void TweaksView_HasGameBoostCard() + { + var serialized = XDocument.Load(RepositoryFile("Views", "SystemTweaksView.xaml")).ToString(SaveOptions.DisableFormatting); + + Assert.Contains("{Binding GameBoostEnabled, Mode=TwoWay}", serialized, StringComparison.Ordinal); + Assert.Contains("{Binding GameBoostIsolation, Mode=TwoWay}", serialized, StringComparison.Ordinal); + Assert.Contains("{Binding SaveGameBoostListsCommand}", serialized, StringComparison.Ordinal); + Assert.Contains("{Binding GameBoostDecisionText}", serialized, StringComparison.Ordinal); + } + + [Fact] + public void EveryLocale_HasTheGameBoostKeys() + { + var english = GameBoostKeys("en-US"); + Assert.Equal(19, english.Count); + foreach (var language in new[] { "it-IT", "de-DE", "es-ES", "fr-FR", "ru-RU", "zh-CN" }) + { + Assert.Equal(english, GameBoostKeys(language)); + } + } + + private static List GameBoostKeys(string language) + { + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + return XDocument.Load(RepositoryFile("Locales", $"{language}.xaml")) + .Descendants() + .Select(element => (string?)element.Attribute(x + "Key")) + .Where(key => key != null && key.StartsWith("GameBoost_", StringComparison.Ordinal)) + .Select(key => key!) + .ToList(); + } + + private static string RepositoryFile(params string[] parts) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) + { + directory = directory.Parent; + } + + return Path.Combine([directory!.FullName, .. parts]); + } + } +} diff --git a/ViewModels/SystemTweaksViewModel.GameBoost.cs b/ViewModels/SystemTweaksViewModel.GameBoost.cs new file mode 100644 index 0000000..f40949b --- /dev/null +++ b/ViewModels/SystemTweaksViewModel.GameBoost.cs @@ -0,0 +1,183 @@ +namespace ThreadPilot.ViewModels +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using CommunityToolkit.Mvvm.ComponentModel; + using CommunityToolkit.Mvvm.Input; + using ThreadPilot.Models; + using ThreadPilot.Services; + + /// + /// Game Boost card on the Tweaks page. Every switch saves at once; the session service reacts to the + /// settings change, so turning it off restores the running game without a restart. + /// + public partial class SystemTweaksViewModel + { + private readonly IApplicationSettingsService? settingsService; + private readonly IGameSessionService? gameSessionService; + private bool isLoadingGameBoost; + + [ObservableProperty] + private bool isGameBoostAvailable; + + [ObservableProperty] + private bool gameBoostEnabled; + + [ObservableProperty] + private bool gameBoostAutomatic = true; + + [ObservableProperty] + private bool gameBoostIsolation; + + [ObservableProperty] + private bool gameBoostAboveNormal; + + [ObservableProperty] + private string gameBoostDecisionText = string.Empty; + + [ObservableProperty] + private string gameBoostManualGamesText = string.Empty; + + [ObservableProperty] + private string gameBoostExcludedGamesText = string.Empty; + + [ObservableProperty] + private string gameBoostBackgroundText = string.Empty; + + internal async Task LoadGameBoostAsync() + { + if (this.settingsService == null || this.gameSessionService == null) + { + return; + } + + var settings = this.settingsService.Settings; + await InvokeOnUiAsync(() => + { + this.isLoadingGameBoost = true; + try + { + this.IsGameBoostAvailable = true; + this.GameBoostEnabled = settings.GameBoostEnabled; + this.GameBoostIsolation = settings.GameBoostMode == GameBoostMode.Isolation; + this.GameBoostAutomatic = !this.GameBoostIsolation; + this.GameBoostAboveNormal = settings.GameBoostAboveNormalPriority; + this.GameBoostManualGamesText = string.Join(Environment.NewLine, settings.GameBoostManualGames); + this.GameBoostExcludedGamesText = string.Join(Environment.NewLine, settings.GameBoostExcludedGames); + this.GameBoostBackgroundText = string.Join(Environment.NewLine, settings.GameBoostBackgroundProcesses); + } + finally + { + this.isLoadingGameBoost = false; + } + }); + + await this.RefreshGameBoostDecisionTextAsync(); + } + + [RelayCommand] + private Task SaveGameBoostListsAsync() => this.SaveGameBoostAsync(); + + partial void OnGameBoostEnabledChanged(bool value) => this.QueueGameBoostSave(); + + partial void OnGameBoostAboveNormalChanged(bool value) => this.QueueGameBoostSave(); + + partial void OnGameBoostIsolationChanged(bool value) + { + if (this.GameBoostAutomatic == value) + { + this.GameBoostAutomatic = !value; + } + + this.QueueGameBoostSave(); + } + + partial void OnGameBoostAutomaticChanged(bool value) + { + if (this.GameBoostIsolation == value) + { + this.GameBoostIsolation = !value; + } + } + + private void QueueGameBoostSave() + { + if (!this.isLoadingGameBoost) + { + TaskSafety.FireAndForget( + this.SaveGameBoostAsync(), + ex => this.SetError(this.Localize("GameBoost_SaveFailed", "Could not save Game Boost settings"), ex)); + } + } + + private async Task SaveGameBoostAsync() + { + if (this.settingsService == null) + { + return; + } + + var updated = (ApplicationSettingsModel)this.settingsService.Settings.Clone(); + updated.GameBoostEnabled = this.GameBoostEnabled; + updated.GameBoostMode = this.GameBoostIsolation ? GameBoostMode.Isolation : GameBoostMode.Automatic; + updated.GameBoostAboveNormalPriority = this.GameBoostAboveNormal; + updated.GameBoostManualGames = SplitLines(this.GameBoostManualGamesText); + updated.GameBoostExcludedGames = SplitLines(this.GameBoostExcludedGamesText); + updated.GameBoostBackgroundProcesses = SplitLines(this.GameBoostBackgroundText); + + await this.settingsService.UpdateSettingsAsync(updated); + await this.LogUserActionAsync( + "GameBoostSettingsChanged", + $"Game Boost {(updated.GameBoostEnabled ? "on" : "off")}, {updated.GameBoostMode}", + "GameBoost"); + await this.RefreshGameBoostDecisionTextAsync(); + } + + private async Task RefreshGameBoostDecisionTextAsync() + { + if (this.gameSessionService == null) + { + return; + } + + var decision = await this.gameSessionService.GetDecisionAsync(); + var game = decision.GameSelection?.GlobalLogicalProcessorIndexes.Count ?? 0; + var background = decision.BackgroundSelection?.GlobalLogicalProcessorIndexes.Count ?? 0; + var text = decision.Kind switch + { + GameBoostTopologyKind.AmdAsymmetricCache => this.Localize( + "GameBoost_Decision_AmdVCache", + "Game on the CCD with 3D V-Cache ({0} CPUs), selected background apps on the other CCD ({1} CPUs).", + game, + background), + GameBoostTopologyKind.AmdSymmetricMultiL3 when decision.GameSelection != null => this.Localize( + "GameBoost_Decision_AmdCcd0", + "Game on CCD0 ({0} CPUs), background apps on the other CCD ({1} CPUs).", + game, + background), + GameBoostTopologyKind.AmdSymmetricMultiL3 => this.Localize( + "GameBoost_Decision_AmdBackgroundOnly", + "Windows places the game; selected background apps move off CCD0 ({0} CPUs). Isolation also keeps the game on CCD0.", + background), + GameBoostTopologyKind.IntelHybrid => this.Localize( + "GameBoost_Decision_IntelHybrid", + "Game on the P-cores ({0} CPUs), selected background apps on the E-cores ({1} CPUs).", + game, + background), + _ => this.Localize( + "GameBoost_Decision_Unsupported", + "On this CPU Windows decides the cores; Game Boost only applies the optional priority."), + }; + + await InvokeOnUiAsync(() => this.GameBoostDecisionText = text); + } + + private static List SplitLines(string text) => + (text ?? string.Empty) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/ViewModels/SystemTweaksViewModel.cs b/ViewModels/SystemTweaksViewModel.cs index 6d27f35..5ad64a5 100644 --- a/ViewModels/SystemTweaksViewModel.cs +++ b/ViewModels/SystemTweaksViewModel.cs @@ -31,12 +31,16 @@ public SystemTweaksViewModel( ILocalizationService localizationService, ILogger logger, IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null) + IActivityAuditService? activityAuditService = null, + IApplicationSettingsService? settingsService = null, + IGameSessionService? gameSessionService = null) : base(logger, enhancedLoggingService, activityAuditService) { this.systemTweaksService = systemTweaksService; this.notificationService = notificationService; this.localizationService = localizationService; + this.settingsService = settingsService; + this.gameSessionService = gameSessionService; this.systemTweaksService.TweakStatusChanged += this.OnTweakStatusChanged; this.localizationService.LanguageChanged += this.OnLanguageChanged; @@ -60,6 +64,7 @@ private void InitializeTweakItems() [RelayCommand] public async Task LoadAsync() { + await this.LoadGameBoostAsync(); await this.RefreshAllTweaksAsync(); } @@ -314,7 +319,11 @@ private string GetStatusText(TweakStatus status) => !status.IsAvailable ? this.Localize("SystemTweaks_StatusNotAvailable", "Not available") : this.Localize(status.IsEnabled ? "SystemTweaks_StatusEnabled" : "SystemTweaks_StatusDisabled", status.IsEnabled ? "Enabled" : "Disabled"); - private void OnLanguageChanged(object? sender, string language) => this.ApplyLocalizedTweakText(); + private void OnLanguageChanged(object? sender, string language) + { + this.ApplyLocalizedTweakText(); + TaskSafety.FireAndForget(this.RefreshGameBoostDecisionTextAsync(), ex => this.Logger.LogDebug(ex, "Game Boost text refresh failed")); + } private void OnTweakStatusChanged(object? sender, TweakStatusChangedEventArgs e) { diff --git a/Views/SystemTweaksView.xaml b/Views/SystemTweaksView.xaml index 61b0205..958516d 100644 --- a/Views/SystemTweaksView.xaml +++ b/Views/SystemTweaksView.xaml @@ -132,7 +132,105 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +