diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea92ac2..9f41b5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} + prerelease: ${{ steps.version.outputs.prerelease }} steps: - name: Checkout @@ -38,7 +39,7 @@ jobs: shell: pwsh run: | $refName = "${{ github.ref_name }}" - if ($refName -match '^v\d+\.\d+\.\d+$') { + if ($refName -match '^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') { $version = $refName.TrimStart('v') $tag = $refName } @@ -52,6 +53,8 @@ jobs: $tag = "v$version" } + $prerelease = $version.Contains('-').ToString().ToLowerInvariant() + "prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -205,6 +208,7 @@ jobs: } - name: Generate winget manifests + if: steps.version.outputs.prerelease != 'true' shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -273,6 +277,7 @@ jobs: path: artifacts/release/SHA256SUMS.txt - name: Upload winget manifests + if: steps.version.outputs.prerelease != 'true' uses: actions/upload-artifact@v4 with: name: winget-manifests @@ -342,6 +347,7 @@ jobs: path: release-assets - name: Download winget manifests + if: needs.build.outputs.prerelease != 'true' uses: actions/download-artifact@v4 with: name: winget-manifests @@ -361,7 +367,9 @@ jobs: compgen -G "release-assets/installer/*.exe" > /dev/null || { echo "Missing installer executable in release-assets/installer"; exit 1; } compgen -G "release-assets/packages/*.zip" > /dev/null || { echo "Missing release package zip in release-assets/packages"; exit 1; } test -f "release-assets/SHA256SUMS.txt" || { echo "Missing release checksums file"; exit 1; } - find "winget-manifests" -type f -name "*.yaml" | grep -q . || { echo "Missing winget manifest yaml files"; exit 1; } + if [ "${{ needs.build.outputs.prerelease }}" != "true" ]; then + find "winget-manifests" -type f -name "*.yaml" | grep -q . || { echo "Missing winget manifest yaml files"; exit 1; } + fi test -f "sbom/manifest.spdx.json" || { echo "Missing SBOM manifest"; exit 1; } - name: Stage public GitHub release assets @@ -394,7 +402,11 @@ jobs: shell: bash run: | set -euo pipefail - git-cliff --config cliff.toml --latest > release-notes.md + if [ "${{ needs.build.outputs.prerelease }}" = "true" ]; then + cp "docs/releases/${{ needs.build.outputs.tag }}.md" release-notes.md + else + git-cliff --config cliff.toml --latest > release-notes.md + fi { echo "content< diff --git a/Installer/setup.iss b/Installer/setup.iss index 61a7125..6c28679 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -11,7 +11,7 @@ #endif #ifndef MyAppVersion - #define MyAppVersion "1.7.6" + #define MyAppVersion "1.7.7-beta.2" #endif #ifndef MyAppSourceDir diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index 0f46f79..88f5d72 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -15,12 +15,12 @@ Höhe anfordern - Prozessmanagement + Prozesse CPU-Masken Energiepläne Regeln Diagnose - ThreadPilot-Aktivität + Aktivität Optimierungen Einstellungen @@ -42,8 +42,8 @@ Overlay zum Laden der Anwendung beim Start - ThreadPilot Aktivität - Zeigt von ThreadPilot ausgeführte Aktionen an, einschließlich angewendeter Regeln, Affinitätsänderungen, Prioritätsänderungen, Energieplanänderungen, Einstellungsänderungen, Optimierungen, Optimierungen und Meldungen zu sicheren Fehlern. + Aktivität + Was ThreadPilot geändert hat und was fehlgeschlagen ist. Suche: Kategorie: Ebene: @@ -71,7 +71,6 @@ Aktivitätsdetails Zeitstempel: Status: - Kategorie: Nachricht: Einzelheiten: Korrelations-ID: @@ -85,8 +84,7 @@ CPU-Masken - Erstellen Sie wiederverwendbare CPU-Affinitätsvoreinstellungen für die Verwendung pro Prozess. - Wenn Sie hier eine Maske auswählen, wird nur die Voreinstellung bearbeitet. Die Affinität von CPU wird erst geändert, wenn Sie sie auf einen Prozess anwenden oder in einer Prozessregel speichern. + Wiederverwendbare Kernauswahl für Prozesse und Regeln. Neu Löschen Maske duplizieren @@ -119,7 +117,6 @@ Aktive Prozesse Top-CPU- und Speicherverbraucher mit Regelerstellung mit einem Klick. - Suchen Sie Prozesse nach Namen Nur durch Regeln gesichert Nur umsetzbar Regelauswirkungen @@ -163,7 +160,7 @@ Energiepläne - Verwalten Sie den aktiven Windows-Energieplan und importieren Sie benutzerdefinierte .pow-Profile + Aktiven Windows-Plan wechseln oder .pow-Dateien importieren. Windows Energiepläne Wählen Sie den Windows-Plan aus, den Sie aktivieren möchten. Als aktiven Windows-Plan festlegen @@ -182,7 +179,7 @@ Aktiv - Regeln und Automatisierung + Regeln Die Automatisierungsüberwachung überwacht Start-/Stopp-Ereignisse und wendet konfigurierte Energiesparpläne, CPU-Masken und Prioritätsregeln an. Regeleditor Erstellen Sie eine Regel aus einer ausführbaren Datei oder einem laufenden Prozess, um den Energieplan, die CPU-Maske und das Prioritätsverhalten zu automatisieren. @@ -212,7 +209,7 @@ Löschen Sie die ausgewählte ausführbare Datei Aktuelle Regeln - Definieren Sie Regeln pro Prozess. Energiepläne sind global; Masken und Prioritäten gelten für Matching-Prozesse. + Was ThreadPilot beim Start eines Prozesses automatisch anwendet. Entfernen Entfernen Sie die ausgewählte Zuordnung Standard-Energieplan (Fallback, wenn keine Regel übereinstimmt) @@ -220,15 +217,12 @@ Legen Sie den Standard-Energieplan fest Einstellungen für die Automatisierungsüberwachung - Ereignisbasierte Automatisierung aktivieren (WMI) - Aktivieren Sie die Automatisierungs-Fallback-Abfrage - Abfrageintervall (Sekunden): Verzögerung der Energieplanänderung (ms): Verhindern Sie doppelte Energieplanänderungen Konfiguration speichern Noch keine Automatisierungsregeln Status: - Automatisierungsüberwachung + Überwachung Starten Sie die Automatisierungsüberwachung Stoppen Sie die Automatisierungsüberwachung @@ -237,9 +231,9 @@ Ausgewählte ausführbare Datei: Beschreibung: Automatisierungsdienst - Regeln - Gespeicherte Prozessregeln - Regeln, die auf der Registerkarte Prozesse gespeichert wurden. ThreadPilot wendet sie bei jedem Start des Prozesses erneut an, solange die Automatisierungsüberwachung aktiv ist. Sie sind getrennt von den Energieplanregeln oben, die den globalen Windows-Energieplan umschalten. + Energieplan-Wechsel + Prozessregeln + Auf der Seite „Prozesse“ gespeichert. Werden bei jedem Prozessstart angewendet, solange die Überwachung läuft. Prozess Wendet an Modus @@ -252,7 +246,7 @@ E/A-Priorität CPU-Zuweisungsmodus System wach halten, solange dieser Prozess läuft - Wählen Sie eine gespeicherte CPU-Maske, um die Kerne dieser Regel festzulegen. Die Auswahl einzelner Kerne finden Sie auf der Registerkarte Prozesse. + Wählen Sie eine gespeicherte CPU-Maske für die Kerne dieser Regel. Masken werden auf der Seite „CPU-Masken“ bearbeitet. Änderungen speichern Noch keine gespeicherten Prozessregeln. Klicken Sie auf der Registerkarte Prozesse mit der rechten Maustaste auf einen Prozess und wählen Sie Regeln. Ausführbar @@ -263,8 +257,7 @@ Prozesspriorität: - Prozessmanagement - Suchen, filtern und steuern Sie aktive Prozesskonfigurationen + Prozesse Suchen Sie Prozesse nach Namen Prozesssuche Windows-Systemprozesse ausblenden @@ -278,45 +271,25 @@ Sortieren nach: Wählen Sie aus, wie die Prozessliste sortiert werden soll - Ausgewählter Prozess Kein Prozess ausgewählt - Wählen Sie einen Prozess aus der Tabelle aus, um Affinitäts-, Prioritäts-, Energieplan- und Regelaktionen zu aktivieren. - Energieplan / Ausstehende Einstellungen - Wählen Sie den Energieplan aus, den Sie manuell aktivieren oder per Schnellanwendung einschließen möchten - Legen Sie den Energieplan fest - Aktivieren Sie den ausgewählten Windows-Energieplan Ausstehende Kernmaske - Wählen Sie eine Maske zum Vormerken. Erst CPU-Zuweisung anwenden ändert den Zustand. - CPU-Zuweisung anwenden + Kerne anwenden Wendet die CPU-Auswahl im gewählten Modus an und überprüft den Windows-Status CPU-Zuweisung anwenden und als Regel speichern - Aktuelle Prozesseinstellungen grundsätzlich speichern. Diese Einstellungen werden automatisch angewendet, wenn dieser Prozess in Zukunft startet. Legen Sie die Priorität CPU fest - Wenden Sie die ausgewählte Priorität auf den Prozess an - Priorität durch Registrierung erzwingen - Wenden Sie die Priorität über die Registrierung Windows an. Der Prozess muss neu gestartet werden, damit die Änderungen wirksam werden. Diese Einstellung bleibt über Systemneustarts hinweg bestehen. Priorität festlegen Die Echtzeitpriorität wird von ThreadPilot blockiert, da Windows dadurch instabil werden oder nicht mehr reagieren kann. Echtzeit (blockiert) - Ruhezustand während des Prozesses verhindern - Hält das System aktiv, solange der Prozess läuft. Der Bildschirm darf ausgehen und expliziter Ruhezustand wird nicht blockiert. - Profil / Regel - Geben Sie einen Profilnamen ein, um Einstellungen zu speichern oder zu laden - Profil speichern - Speichern Sie die aktuelle Affinität und Priorität von CPU als wiederverwendbares Profil - Profil laden - Laden Sie ein zuvor gespeichertes Profil - Aktuelle Einstellungen als Regel speichern + Als Regel speichern Speichert eine prozessbezogene Regel, die ThreadPilot beim nächsten Start dieses Prozesses automatisch erneut anwendet, solange die Automatisierungsüberwachung aktiviert ist. Diese Regeln sind unabhängig von der Registerkarte „Regeln“, der Zuordnungen von Prozessen zu Energiesparplänen verwaltet. Gespeicherte Regel löschen Entfernt die gespeicherte Regel für diesen Prozess, damit ThreadPilot sie beim Start des Prozesses nicht mehr anwendet. Letzte ThreadPilot-Aktion: - Aktualisieren Aktualisieren Sie die Prozessliste Mehr laden Laden Sie weitere Prozesse @@ -324,38 +297,23 @@ Laden Sie weitere Prozesse Liste der laufenden Prozesse Virtualisierte Prozesstabelle mit Sortierung und Auswahl - Die Automatisierungsüberwachung ist deaktiviert. - Aktivieren Sie die Automatisierungsüberwachung unter Regeln und Automatisierung oder im Infobereich, um die Prozessliste anzuzeigen und zu aktualisieren. Prozessname Fenstertitel CPU-Auslastung Speicherauslastung - CPU-Auswahl vorgemerkt. Sie wird erst über CPU-Zuweisung anwenden übernommen. - Affinität, die derzeit von Windows für den ausgewählten Prozess gemeldet wird Schreibgeschützte Vorschau der CPU-Auswahl — ändern Sie sie oben unter 'Ausstehende Kernmaske'. Nur Vorschau — diese Markierungen zeigen die ausgewählten CPUs an, sie sind nicht anklickbar. Wählen Sie oben unter 'Ausstehende Kernmaske' eine Maske aus, um eine andere Auswahl vorzubereiten (Masken werden im Tab CPU-Masken erstellt). Ausgewählt Nicht ausgewählt Nicht verfügbar Öffnen Sie die Registerkarte CPU Masken, um eine neue benutzerdefinierte Maske zu erstellen - Zeigt an, ob Hyper-Threading (Intel) oder SMT (AMD) auf diesem System vorhanden und aktiv ist Prozessinformationen kopieren Öffnen Sie den Speicherort der ausführbaren Datei CPU-Sätze löschen - Ausstehende Einstellungen anwenden - Wenden Sie die ausstehende Affinität und den ausgewählten Energieplan auf den ausgewählten Prozess an - Regeln und Änderungen werden von ThreadPilot nur angewendet, wenn sie konfiguriert sind. - Aktueller Prozessstatus - Erweiterte Affinitätsauswahl Wählen Sie die ausstehende Maske für Affinitätsaktionen im Zeilenkontextmenü aus - Ausgewählter Energieplan - Als Regel speichern - Kein sichtbarer Fenstertitel - Stapelverarbeitung - insgesamt) Priorität CPU: nicht verfügbar Speicher: nicht verfügbar @@ -386,11 +344,8 @@ Benutzerdefiniert Hat Fenster - Fenster - PID ID Speicher (MB) - Affinität Priorität Regeln Name @@ -404,7 +359,7 @@ Anwendungseinstellungen - Konfigurieren Sie Benachrichtigungen, die Taskleiste und das Anwendungsverhalten + Benachrichtigungen, Infobereich, Autostart und Sprache. Benachrichtigungen Benachrichtigungen aktivieren Benachrichtigungsebene @@ -433,13 +388,11 @@ Detaillierte Tooltips anzeigen Grundeinstellungen - Autostart Beginnen Sie mit Windows ThreadPilot automatisch starten, wenn Windows startet Low-Impact-Modus im Hintergrund Verringern Sie die Prozesspriorität von ThreadPilot, während es minimiert oder im Fach ausgeblendet ist. - Aussehen Verwenden Sie ein dunkles Thema Wendet ein globales Dunkel/Hell-Thema auf alle Registerkarten an @@ -478,14 +431,11 @@ Melde Fehler oder unerwartetes Verhalten über GitHub Issues. Problem melden Über - Version: Copyright © 2026 PrimeBuild - Übersetzer: Ylimhs Lizenz: AGPLv3 Suchen Sie nach Updates Laden Sie das Update herunter und installieren Sie es Beim Start automatisch prüfen - Prüfintervall (Tage): Fügen Sie Vorabversionsaktualisierungen hinzu Neueste Version: Zuletzt überprüft: @@ -565,8 +515,8 @@ Speichern - Systemoptimierungen und -optimierungen - Konfigurieren Sie Windows Systemoptimierungen und Leistungsoptimierungen + Optimierungen + Windows-Einstellungen, die die Leistung beeinflussen. Alle aktualisieren Alle Optimierungsstatus aktualisieren Schalten Sie diese Windows-Optimierung um @@ -606,8 +556,27 @@ GPU-MSI-Modus Steuert MSI nur, wenn der Grafiktreiber MSISupported bereits bereitstellt. Neustart erforderlich. Einstellungen öffnen - EIN - AUS + 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 @@ -629,18 +598,10 @@ {0} konnte nicht aktiviert werden {0} konnte nicht deaktiviert werden Systemoptimierung fehlgeschlagen - Fehler bei {0} {1} Fehler beim Umschalten von {0} - Fehler beim Umschalten von {0}: {1} - aktivieren - deaktivieren - aktiviert - deaktiviert Aktiviert Deaktiviert Nicht verfügbar - Systemoptimierungen werden geladen... - Systemoptimierungen wurden erfolgreich geladen Öffnen Sie das Dashboard @@ -665,18 +626,6 @@ 💻 CPU: {0:F1}% | RAM: {1:F1}% | {2} - Windows hat diese Änderung abgelehnt. Der Prozess erfordert möglicherweise Administratorrechte oder ist möglicherweise geschützt. - Der Prozess scheint durch Anti-Cheat- oder Prozessschutz geschützt zu sein. ThreadPilot wird nicht versuchen, es zu umgehen. - Der Administratormodus kann bei normalen Berechtigungsproblemen hilfreich sein, kann jedoch Anti-Cheat- oder geschützte Prozessbeschränkungen nicht umgehen. - Diese CPU-Auswahl kann durch ältere Affinitäts-APIs in dieser Topologie nicht sicher dargestellt werden. Für diese Auswahl sind CPU Sets erforderlich. - Diese CPU-Auswahl stimmt nicht mit der aktuellen CPU-Topologie überein. Überprüfen Sie die Voreinstellung oder erstellen Sie sie neu. - Der Prozess wurde beendet, bevor ThreadPilot die Änderung übernehmen konnte. - Windows CPU Sätze sind nicht verfügbar oder diese Auswahl wurde abgelehnt. ThreadPilot verwendet nur, wenn möglich, einen sicheren Fallback. - Eine hohe Priorität kann die Reaktionsfähigkeit einiger Workloads verbessern, kann jedoch die Reaktionsfähigkeit des Systems verringern. - Die Echtzeitpriorität wird von ThreadPilot blockiert, da Windows dadurch instabil werden oder nicht mehr reagieren kann. - Eine dauerhafte Startzeitpriorität wird möglicherweise für normale Prozesse unterstützt, sie umgeht jedoch keine geschützten Prozesse oder Anti-Cheat-Einschränkungen. - Wendet gespeicherte Regeln an, wenn ein Abgleichsprozess beginnt. Einige geschützte oder Anti-Cheat-Prozesse lehnen Änderungen möglicherweise ab. Der Administratormodus kann bei normalen Berechtigungsproblemen hilfreich sein, den Schutz jedoch nicht umgehen. - Der Prozess scheint durch Anti-Cheat- oder Prozessschutz geschützt zu sein. ThreadPilot wird nicht versuchen, es zu überschreiben. Energieplan geändert @@ -728,7 +677,6 @@ CPU-Zuweisung Standardmäßige CPU-Zuweisung Legt fest, wie neue manuelle Aktionen und Regeln die ausgewählten CPUs zuweisen. - Informationen zum CPU-Zuweisungsmodus Affinitätsmaske Harte Einschränkung. Multi-Group-Auswahlen werden pro Thread angewendet; neue Threads werden bei aktiver Automatisierungsüberwachung erfasst. Idealer Prozessor @@ -747,20 +695,8 @@ Dieser vorgemerkte Modus folgt einer passenden Regel; andernfalls gilt der globale Standard. - ThreadPilot erfordert Administratorrechte, um Prozessaffinität und Energiepläne zu verwalten. Möchten Sie die Anwendung mit Administratorrechten neu starten? - Administratorrechte erforderlich - Neustart mit Administratorrechten fehlgeschlagen. Bitte führen Sie ThreadPilot manuell als Administrator aus. - Erhöhung fehlgeschlagen - Ausführung mit Administratorrechten - Läuft mit eingeschränkten Berechtigungen - Der Wert von Core Parking konnte nicht über powercfg abgefragt werden. - Der Wert von C-States konnte nicht über powercfg abgefragt werden. - Prefetch Registrierungsschlüssel nicht gefunden - Power Throttling ist auf diesem System nicht verfügbar - PriorityControl Registrierungsschlüssel nicht gefunden - Desktop-Registrierungsschlüssel nicht gefunden Regeln in Anwendungsreihenfolge: {0}. Bei Überschneidungen gilt die spätere Regel. @@ -772,7 +708,7 @@ Anwenden Automatisierung pausiert. Manuelle Änderungen funktionieren weiterhin; gespeicherte Regeln werden erst nach Fortsetzen der Überwachung angewendet. - Letzte Prioritätsänderung rückgängig machen + Letzte Änderung rückgängig Prozesse angezeigt Nicht gespeicherte Änderungen Vorherige Priorität wiederhergestellt. @@ -787,4 +723,20 @@ Effizienzmodus: {0} Effizienzmodus nicht verfügbar Effizienzmodus: {0} (ohne Wirkung: Energiedrosselung ist in den Optimierungen deaktiviert) + + + Filter + Spalten + Priorität + CPU-Kerne + Regel + CPU + Speicher + E/A + Effizienz + Wählen Sie einen Prozess, um Priorität, Kerne und Regel zu sehen und zu ändern. + Nicht festgelegt + hält den PC wach + nichts + (ohne Namen) diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 7058e89..8fca21c 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -15,12 +15,12 @@ Request Elevation - Process Management - CPU Masks - Power Plans + Processes + CPU masks + Power plans Rules Diagnostics - ThreadPilot Activity + Activity Tweaks Settings @@ -42,8 +42,8 @@ Application startup loading overlay - ThreadPilot Activity - Shows actions performed by ThreadPilot, including applied rules, affinity changes, priority changes, power plan changes, settings changes, tweaks, optimizations, and safe failure messages. + Activity + What ThreadPilot changed, and what failed. Search: Category: Level: @@ -71,7 +71,6 @@ Activity Details Timestamp: Status: - Category: Message: Details: Correlation ID: @@ -85,8 +84,7 @@ CPU Masks - Create reusable CPU affinity presets for per-process use. - Selecting a mask here only edits the preset. It does not change CPU affinity until you apply it to a process or save it in a process rule. + Reusable core selections for processes and rules. New Delete Duplicate Mask @@ -119,7 +117,6 @@ Active Processes Top CPU and memory consumers with one-click rule creation. - Search processes by name Rule-backed only Actionable only Rule Impact @@ -163,7 +160,7 @@ Power Plans - Manage the active Windows power plan and import custom .pow profiles + Switch the active Windows plan or import .pow files. Windows Power Plans Select the Windows plan to make active. Set as Active Windows Plan @@ -182,7 +179,7 @@ Active - Rules & Automation + Rules Automation monitoring watches process start/stop events and applies configured power plan, CPU mask, and priority rules. Rule Editor Create a rule from an executable or running process to automate power plan, CPU mask, and priority behavior. @@ -212,7 +209,7 @@ Clear the selected executable Current Rules - Define per-process rules. Power plans are global; masks and priorities apply to matching processes. + What ThreadPilot applies automatically when a process starts. Remove Remove the selected association Default Power Plan (fallback when no rule matches) @@ -220,15 +217,12 @@ Set Default Power Plan Automation Monitoring Settings - Enable Event-Based Automation (WMI) - Enable Automation Fallback Polling - Polling Interval (seconds): Power Plan Change Delay (ms): Prevent Duplicate Power Plan Changes Save Configuration No automation rules yet Status: - Automation Monitoring + Monitoring Start Automation Monitoring Stop Automation Monitoring @@ -237,9 +231,9 @@ Selected Executable: Description: Automation Service - Rules - Saved process rules - Rules saved from the Process tab. ThreadPilot re-applies them to that process every time it starts, while automation monitoring is on. These are separate from the power plan rules above, which switch the global Windows power plan. + Power plan switching + Process rules + Saved from the Processes page. Applied every time the process starts, while monitoring is on. Process Applies Mode @@ -252,7 +246,7 @@ I/O priority CPU assignment mode Keep the system awake while this process runs - Pick a saved CPU mask to set which cores this rule pins to. The per-core picker lives in the Process tab. + Pick a saved CPU mask for this rule's cores. Masks are edited on the CPU masks page. Save changes No saved process rules yet. Right-click a process in the Process tab and choose Rules to save one. Executable @@ -263,8 +257,7 @@ Process Priority: - Process Management - Search, filter, and control active process configurations + Processes Search processes by name Process search Hide Windows system processes @@ -278,45 +271,25 @@ Sort by: Choose how to sort the process list - Selected Process No process selected - Select a process from the table to enable affinity, priority, power plan, and rule actions. - Power Plan / Pending Settings - Choose the power plan to activate manually or include with quick apply - Set Power Plan - Activate the selected Windows power plan Pending core mask - Select a mask to stage it. Nothing changes until Apply CPU Assignment is clicked. - Apply CPU Assignment + Apply cores Apply the pending CPU selection with the selected mode and verify the Windows state Apply CPU Assignment and Save as Rule - Save current process settings as a rule. These settings will be automatically applied when this process starts in the future. Set CPU Priority - Apply the selected priority to the process - Enforce Priority by Registry - Apply priority through Windows registry. Process must be restarted for changes to take effect. This setting persists across system reboots. Set Priority Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive. Realtime (blocked) - Prevent sleep while process runs - Keeps the system awake while this process is running. The display may still turn off, and explicit sleep is not blocked. - Profile / Rule - Enter a profile name to save or load settings - Save Profile - Save current CPU affinity and priority as a reusable profile - Load Profile - Load a previously saved profile - Save Current Settings as Rule + Save as rule Saves a per-process rule that ThreadPilot re-applies automatically the next time this process starts, while automation monitoring is enabled. These rules are separate from the Rules tab, which manages process-to-power-plan associations. Delete Saved Rule Removes the saved rule for this process, so ThreadPilot stops re-applying it when the process starts. Last ThreadPilot action: - Refresh Refresh the process list Load More Load more processes @@ -324,38 +297,23 @@ Load more processes Running process list Virtualized process table with sorting and selection - Automation monitoring is disabled. - Enable Automation Monitoring from Rules & Automation or the system tray to show and refresh the process list. Process Name Window Title CPU Usage Memory Usage - CPU selection staged in the UI. It is applied only through Apply CPU Assignment. - Affinity currently reported by Windows for the selected process Read-only preview of the CPU selection — choose a mask under 'Pending core mask' to change it. Preview only — these tags show which CPUs are selected, they are not clickable. Choose a mask from 'Pending core mask' above to stage a different selection (create masks in the CPU Masks tab). Selected Not selected Unavailable Open CPU Masks tab to create a new custom mask - Shows whether Hyper-Threading (Intel) or SMT (AMD) is present and active on this system Copy Process Info Open Executable Location Clear CPU Sets - Apply Pending Settings - Apply the pending affinity and selected power plan to the selected process - Rules and changes are applied by ThreadPilot only when configured. - Current process status - Advanced affinity picker Select the pending mask for row context-menu affinity actions - Selected power plan - Save as Rule - No visible window title - Batch - total) Priority CPU: unavailable Memory: unavailable @@ -386,11 +344,8 @@ Custom Has Window - Window - PID ID Memory (MB) - Affinity Priority Rules Name @@ -404,7 +359,7 @@ Application Settings - Configure notifications, system tray, and application behavior + Notifications, tray, startup and language. Notifications Enable notifications Notification Level @@ -433,13 +388,11 @@ Show detailed tooltips Basic Preferences - Autostart Start with Windows Automatically start ThreadPilot when Windows starts Low impact mode in background Lower ThreadPilot's process priority while minimized or hidden to tray. - Appearance Use dark theme Applies a global Dark/Light theme across all tabs @@ -478,14 +431,11 @@ Report bugs or unexpected behavior through GitHub Issues. Report an issue About - Version: Copyright © 2026 PrimeBuild - Translator: Ylimhs License: AGPLv3 Check for updates Download and install update Check automatically on startup - Check interval (days): Include prerelease updates Latest version: Last checked: @@ -565,8 +515,8 @@ Save - System Tweaks & Optimizations - Configure Windows system optimizations and performance tweaks + Tweaks + Windows settings that affect performance. Refresh All Refresh all tweak states Toggle this Windows tweak @@ -606,8 +556,27 @@ GPU MSI Mode Controls MSI only when the display driver already exposes MSISupported. A restart is required. Open settings - ON - OFF + 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} @@ -629,18 +598,10 @@ Failed to enable {0} Failed to disable {0} System Tweak Failed - Failed to {0} {1} Error toggling {0} - Error toggling {0}: {1} - enable - disable - enabled - disabled Enabled Disabled Not Available - Loading system tweaks... - System tweaks loaded successfully Open Dashboard @@ -665,18 +626,6 @@ 💻 CPU: {0:F1}% | RAM: {1:F1}% | {2} - Windows denied this change. The process may require administrator rights or may be protected. - The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it. - Administrator mode may help with normal permission issues, but cannot bypass anti-cheat or protected process restrictions. - This CPU selection cannot be safely represented by legacy affinity APIs on this topology. CPU Sets are required for this selection. - This CPU selection does not match the current CPU topology. Review or recreate the preset. - The process exited before ThreadPilot could apply the change. - Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible. - High priority can improve responsiveness for some workloads but may reduce system responsiveness. - Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive. - Persistent launch-time priority may be supported for normal processes, but it does not bypass protected process or anti-cheat restrictions. - Applies saved rules when a matching process starts. Some protected or anti-cheat processes may reject changes. Administrator mode can help with normal permission issues but cannot bypass protection. - The process appears protected by anti-cheat or process protection. ThreadPilot will not try to override it. Power Plan Changed @@ -728,7 +677,6 @@ CPU assignment Default CPU assignment Choose how new manual actions and rules assign the selected CPUs. - CPU assignment mode information Affinity Mask Hard constraint. Multi-group selections are applied per thread; new threads are covered while automation monitoring is enabled. Ideal Processor @@ -747,20 +695,8 @@ This staged mode follows the matching rule when one exists; otherwise it uses the global default. - ThreadPilot requires administrator privileges to manage process affinity and power plans. Would you like to restart the application with administrator privileges? - Administrator Privileges Required - Failed to restart with administrator privileges. Please manually run ThreadPilot as administrator. - Elevation Failed - Running with Administrator privileges - Running with limited privileges - Could not query Core Parking value via powercfg - Could not query C-States value via powercfg - Prefetch registry key not found - Power Throttling not available on this system - PriorityControl registry key not found - Desktop registry key not found Rules in apply order: {0}. Later rules win for overlapping settings. @@ -772,7 +708,7 @@ Apply Automation is paused. Manual changes still work; saved rules are not applied until monitoring resumes. - Undo last priority change + Undo last change processes shown Unsaved changes Previous priority restored. @@ -787,4 +723,20 @@ Efficiency mode: {0} Efficiency mode unavailable Efficiency mode: {0} (no effect: power throttling is disabled in Tweaks) + + + Filters + Columns + Priority + CPU cores + Rule + CPU + Memory + I/O + Efficiency + Select a process to see and change its priority, cores and rule. + Not set + keeps the PC awake + nothing + (unnamed) diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 85330ae..1087a59 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -15,12 +15,12 @@ Solicitar elevación - Gestión de procesos + Procesos Máscaras de CPU Planes de energía Reglas Diagnóstico - Actividad de ThreadPilot + Actividad Ajustes Configuración @@ -42,8 +42,8 @@ Superposición de carga de inicio de la aplicación - ThreadPilot Actividad - Muestra las acciones realizadas por ThreadPilot, incluidas reglas aplicadas, cambios de afinidad, cambios de prioridad, cambios de plan de energía, cambios de configuración, ajustes, optimizaciones y mensajes de falla segura. + Actividad + Lo que ThreadPilot ha cambiado y lo que ha fallado. Buscar: Categoría: Nivel: @@ -71,7 +71,6 @@ Detalles de la actividad Marca de tiempo: Estado: - Categoría: Mensaje: Detalles: ID de correlación: @@ -85,8 +84,7 @@ Máscaras de CPU - Cree ajustes preestablecidos de afinidad CPU reutilizables para uso por proceso. - Al seleccionar una máscara aquí solo se edita el ajuste preestablecido. No cambia la afinidad de CPU hasta que la aplica a un proceso o la guarda en una regla de proceso. + Selecciones de núcleos reutilizables para procesos y reglas. Nuevo Eliminar Duplicar máscara @@ -119,7 +117,6 @@ Procesos activos Principales consumidores de memoria y CPU con creación de reglas con un solo clic. - Buscar procesos por nombre Solo respaldado por reglas Sólo procesable Impacto de la regla @@ -163,7 +160,7 @@ Planes de energía - Administre el plan de energía activo Windows e importe perfiles .pow personalizados + Cambia el plan activo de Windows o importa archivos .pow. Windows Planes de energía Seleccione el plan Windows para activarlo. Establecer como plan activo Windows @@ -182,7 +179,7 @@ Activo - Reglas y automatización + Reglas El monitoreo de automatización observa los eventos de inicio/detención del proceso y aplica el plan de energía configurado, la máscara CPU y las reglas de prioridad. Editor de reglas Cree una regla a partir de un proceso ejecutable o en ejecución para automatizar el plan de energía, la máscara CPU y el comportamiento prioritario. @@ -212,7 +209,7 @@ Borrar el ejecutable seleccionado Reglas actuales - Definir reglas por proceso. Los planes de energía son globales; Las máscaras y prioridades se aplican a los procesos de emparejamiento. + Lo que ThreadPilot aplica automáticamente cuando se inicia un proceso. Quitar Eliminar la asociación seleccionada Plan de energía predeterminado (alternativo cuando ninguna regla coincide) @@ -220,15 +217,12 @@ Establecer plan de energía predeterminado Configuración de monitoreo de automatización - Habilitar la automatización basada en eventos (WMI) - Habilitar el sondeo alternativo de automatización - Intervalo de sondeo (segundos): Retraso en el cambio del plan de energía (ms): Evite cambios duplicados en el plan de energía Guardar configuración Aún no hay reglas de automatización Estado: - Monitoreo de automatización + Supervisión Iniciar monitoreo de automatización Detener el monitoreo de automatización @@ -237,9 +231,9 @@ Ejecutable seleccionado: Descripción: Servicio de automatización - Reglas - Reglas de proceso guardadas - Reglas guardadas desde la pestaña Procesos. ThreadPilot las vuelve a aplicar a ese proceso cada vez que se inicia, si la supervisión automática está activada. Son distintas de las reglas de plan de energía de arriba, que cambian el plan de energía global de Windows. + Cambio de plan de energía + Reglas de procesos + Guardadas desde la página Procesos. Se aplican cada vez que el proceso se inicia, con la supervisión activa. Proceso Aplica Modo @@ -252,7 +246,7 @@ Prioridad de E/S Modo de asignación de CPU Impedir la suspensión mientras este proceso se ejecuta - Elige una máscara de CPU guardada para fijar los núcleos de esta regla. El selector núcleo por núcleo está en la pestaña Procesos. + Elige una máscara de CPU guardada para los núcleos de esta regla. Las máscaras se editan en la página Máscaras de CPU. Guardar cambios Aún no hay reglas de proceso guardadas. Haz clic derecho en un proceso en la pestaña Procesos y elige Reglas para guardar una. ejecutable @@ -263,8 +257,7 @@ Prioridad del proceso: - Gestión de procesos - Buscar, filtrar y controlar configuraciones de procesos activos + Procesos Buscar procesos por nombre Búsqueda de procesos Ocultar procesos del sistema Windows @@ -278,45 +271,25 @@ Ordenar por: Elija cómo ordenar la lista de procesos - Proceso seleccionado Ningún proceso seleccionado - Seleccione un proceso de la tabla para habilitar acciones de afinidad, prioridad, plan de energía y reglas. - Plan de energía/Configuraciones pendientes - Elija el plan de energía para activarlo manualmente o incluirlo con aplicación rápida - Establecer plan de energía - Activar el plan de energía Windows seleccionado Máscara de núcleos pendiente - Selecciona una máscara pendiente. Nada cambia hasta pulsar Aplicar asignación de CPU. - Aplicar asignación de CPU + Aplicar núcleos Aplica la selección de CPU con el modo elegido y verifica el estado de Windows Aplicar asignación de CPU y guardar como regla - Guarde la configuración del proceso actual como regla. Esta configuración se aplicará automáticamente cuando este proceso comience en el futuro. Establecer prioridad CPU - Aplicar la prioridad seleccionada al proceso. - Hacer cumplir la prioridad por registro - Aplicar prioridad a través del registro Windows. Se debe reiniciar el proceso para que los cambios surtan efecto. Esta configuración persiste tras los reinicios del sistema. Establecer prioridad La prioridad en tiempo real está bloqueada por ThreadPilot porque puede hacer que Windows sea inestable o no responda. Tiempo real (bloqueado) - Evitar suspensión durante el proceso - Mantiene el sistema activo mientras se ejecuta el proceso. La pantalla puede apagarse y no se bloquea una suspensión explícita. - Perfil / Regla - Ingrese un nombre de perfil para guardar o cargar la configuración - Guardar perfil - Guarde la afinidad y prioridad CPU actuales como un perfil reutilizable - Perfil de carga - Cargar un perfil previamente guardado - Guardar la configuración actual como regla + Guardar como regla Guarda una regla por proceso que ThreadPilot vuelve a aplicar automáticamente la próxima vez que se inicie este proceso, mientras la supervisión de automatización esté activada. Estas reglas son independientes de la pestaña Reglas, que gestiona las asociaciones entre procesos y planes de energía. Eliminar la regla guardada Elimina la regla guardada para este proceso, para que ThreadPilot deje de aplicarla cuando el proceso se inicia. Última acción ThreadPilot: - Actualizar Actualizar la lista de procesos Cargar más Cargar más procesos @@ -324,38 +297,23 @@ Cargar más procesos Lista de procesos en ejecución Mesa de proceso virtualizada con clasificación y selección. - La supervisión de automatización está desactivada. - Active la supervisión de automatización desde Reglas y automatización o desde la bandeja del sistema para mostrar y actualizar la lista de procesos. Nombre del proceso Título de la ventana Uso de CPU Uso de la memoria - Selección de CPU pendiente. Solo se aplica mediante Aplicar asignación de CPU. - Afinidad reportada actualmente por Windows para el proceso seleccionado Vista previa de solo lectura de la selección de CPU — cámbiela en 'Máscara de núcleos pendiente'. Solo vista previa — estas etiquetas muestran qué CPU están seleccionadas, no se pueden pulsar. Elija una máscara en 'Máscara de núcleos pendiente' más arriba para preparar una selección distinta (las máscaras se crean en la pestaña Máscaras de CPU). Seleccionada No seleccionada No disponible Abra la pestaña Máscaras CPU para crear una nueva máscara personalizada - Muestra si Hyper-Threading (Intel) o SMT (AMD) está presente y activo en este sistema. Copiar información del proceso Abrir ubicación ejecutable Borrar conjuntos CPU - Aplicar configuraciones pendientes - Aplicar la afinidad pendiente y el plan de energía seleccionado al proceso seleccionado - ThreadPilot aplica las reglas y los cambios solo cuando están configurados. - Estado actual del proceso - Selector de afinidad avanzado Seleccione la máscara pendiente para las acciones de afinidad del menú contextual de fila - Plan de energía seleccionado - Guardar como regla - No hay título de ventana visible - Lotes - total) Prioridad CPU: no disponible Memoria: no disponible @@ -386,11 +344,8 @@ personalizado Tiene ventana - ventana - PID ID Memoria (MB) - Afinidad Prioridad Reglas Nombre @@ -404,7 +359,7 @@ Configuración de la aplicación - Configurar notificaciones, bandeja del sistema y comportamiento de las aplicaciones + Notificaciones, bandeja, inicio e idioma. Notificaciones Habilitar notificaciones Nivel de notificación @@ -433,13 +388,11 @@ Mostrar información sobre herramientas detallada Preferencias básicas - Inicio automático Comience con Windows Iniciar automáticamente ThreadPilot cuando se inicia Windows Modo de bajo impacto en segundo plano. Reduzca la prioridad del proceso de ThreadPilot mientras está minimizado u oculto en la bandeja. - Apariencia Usar tema oscuro Aplica un tema global Oscuro/Claro en todas las pestañas @@ -478,14 +431,11 @@ Informa de errores o comportamientos inesperados mediante GitHub Issues. Informar de un problema Acerca de - Versión: Copyright © 2026 PrimeBuild - Traductor: Ylimhs Licencia: AGPLv3 Buscar actualizaciones Descargar e instalar la actualización Verificar automáticamente al iniciar - Intervalo de verificación (días): Incluir actualizaciones preliminares Última versión: Última comprobación: @@ -565,8 +515,8 @@ Guardar - Ajustes y optimizaciones del sistema - Configurar Windows optimizaciones del sistema y ajustes de rendimiento + Ajustes + Ajustes de Windows que afectan al rendimiento. Actualizar todo Actualizar todos los estados de ajuste Alternar este ajuste Windows @@ -606,8 +556,27 @@ Modo MSI de GPU Controla MSI solo si el controlador gráfico ya expone MSISupported. Requiere reiniciar. Abrir configuración - SÍ - NO + 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} @@ -629,18 +598,10 @@ No se pudo habilitar {0} No se pudo deshabilitar {0} Error en el ajuste del sistema - No se pudo {0} {1} Error al alternar {0} - Error al alternar {0}: {1} - habilitar - desactivar - habilitado - deshabilitado Habilitado Deshabilitado No disponible - Cargando ajustes en el sistema... - Los ajustes del sistema se cargaron correctamente Abrir panel @@ -665,18 +626,6 @@ 💻 CPU: {0:F1}% | RAM: {1:F1}% | {2} - Windows negó este cambio. El proceso puede requerir derechos de administrador o puede estar protegido. - El proceso aparece protegido por anti-trampas o protección de proceso. ThreadPilot no intentará omitirlo. - El modo de administrador puede ayudar con problemas de permisos normales, pero no puede eludir las restricciones de procesos protegidos o anti-trampas. - Esta selección CPU no se puede representar de forma segura mediante API de afinidad heredadas en esta topología. CPU Se requieren conjuntos para esta selección. - Esta selección CPU no coincide con la topología CPU actual. Revisa o recrea el ajuste preestablecido. - El proceso salió antes de que ThreadPilot pudiera aplicar el cambio. - Windows CPU Los conjuntos no están disponibles o rechazaron esta selección. ThreadPilot utilizará un respaldo seguro solo cuando sea posible. - La prioridad alta puede mejorar la capacidad de respuesta de algunas cargas de trabajo, pero puede reducir la capacidad de respuesta del sistema. - La prioridad en tiempo real está bloqueada por ThreadPilot porque puede hacer que Windows sea inestable o no responda. - La prioridad persistente en el tiempo de inicio puede ser compatible con procesos normales, pero no elude los procesos protegidos ni las restricciones anti-trampas. - Aplica reglas guardadas cuando comienza un proceso de coincidencia. Algunos procesos protegidos o antitrampas pueden rechazar los cambios. El modo de administrador puede ayudar con problemas de permisos normales, pero no puede eludir la protección. - El proceso aparece protegido por anti-trampas o protección de proceso. ThreadPilot no intentará anularlo. Plan de energía cambiado @@ -728,7 +677,6 @@ Asignación de CPU Asignación de CPU predeterminada Elige cómo las nuevas acciones manuales y reglas asignan las CPU seleccionadas. - Información del modo de asignación de CPU Máscara de afinidad Restricción rígida. Las selecciones de varios grupos se aplican por hilo; los nuevos hilos se incluyen con la supervisión automática activa. Procesador ideal @@ -747,20 +695,8 @@ Este modo pendiente sigue la regla coincidente si existe; de lo contrario usa el valor global predeterminado. - ThreadPilot requiere privilegios de administrador para gestionar la afinidad de procesos y los planes de energía. ¿Le gustaría reiniciar la aplicación con privilegios de administrador? - Privilegios de administrador requeridos - No se pudo reiniciar con privilegios de administrador. Ejecute manualmente ThreadPilot como administrador. - Error de elevación - Ejecutar con privilegios de administrador - Ejecutando con privilegios limitados - No se pudo consultar el valor Core Parking a través de powercfg - No se pudo consultar el valor C-States a través de powercfg - Prefetch clave de registro no encontrada - Power Throttling no disponible en este sistema - PriorityControl clave de registro no encontrada - No se encontró la clave de registro del escritorio Reglas en orden de aplicación: {0}. En ajustes superpuestos prevalece la última. @@ -772,7 +708,7 @@ Aplicar Automatización en pausa. Los cambios manuales siguen funcionando; las reglas guardadas no se aplican hasta reanudar la supervisión. - Deshacer el último cambio de prioridad + Deshacer el último cambio procesos mostrados Cambios sin guardar Prioridad anterior restaurada. @@ -787,4 +723,20 @@ Modo de eficiencia: {0} Modo de eficiencia no disponible Modo de eficiencia: {0} (sin efecto: la limitación de energía está desactivada en Ajustes) + + + Filtros + Columnas + Prioridad + Núcleos de CPU + Regla + CPU + Memoria + E/S + Eficiencia + Selecciona un proceso para ver y cambiar su prioridad, núcleos y regla. + Sin definir + mantiene el PC activo + nada + (sin nombre) diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index c00e597..6bc06a7 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -15,12 +15,12 @@ Demander une élévation - Gestion des processus + Processus Masques CPU - Plans d'alimentation + Modes d'alimentation Règles Diagnostic - Activité de ThreadPilot + Activité Optimisations Paramètres @@ -42,8 +42,8 @@ Superposition de chargement au démarrage de l'application - ThreadPilot Activité - Affiche les actions effectuées par ThreadPilot, y compris les règles appliquées, les changements d'affinité, les changements de priorité, les changements de plan d'alimentation, les changements de paramètres, les ajustements, les optimisations et les messages d'échec sûrs. + Activité + Ce que ThreadPilot a modifié et ce qui a échoué. Rechercher : Catégorie : Niveau : @@ -71,7 +71,6 @@ Détails de l'activité Horodatage : Statut : - Catégorie : Message : Détails : ID de corrélation : @@ -85,8 +84,7 @@ Masques CPU - Créez des préréglages d'affinité CPU réutilisables pour une utilisation par processus. - La sélection d'un masque ici modifie uniquement le préréglage. Cela ne modifie pas l'affinité CPU jusqu'à ce que vous l'appliquiez à un processus ou que vous l'enregistriez dans une règle de processus. + Sélections de cœurs réutilisables pour les processus et les règles. Nouveau Supprimer Dupliquer le masque @@ -119,7 +117,6 @@ Processus actifs Principaux consommateurs de CPU et de mémoire avec création de règles en un clic. - Rechercher des processus par nom Basé sur des règles uniquement Actionnable uniquement Impact des règles @@ -163,7 +160,7 @@ Plans d'alimentation - Gérez le plan d'alimentation Windows actif et importez des profils .pow personnalisés + Changez le mode Windows actif ou importez des fichiers .pow. Windows Plans d'alimentation Sélectionnez le plan Windows à rendre actif. Définir comme plan Windows actif @@ -182,7 +179,7 @@ Actif - Règles et automatisation + Règles La surveillance de l'automatisation surveille les événements de démarrage/arrêt du processus et applique le plan d'alimentation configuré, le masque CPU et les règles de priorité. Éditeur de règles Créez une règle à partir d'un processus exécutable ou en cours d'exécution pour automatiser le plan d'alimentation, le masque CPU et le comportement de priorité. @@ -212,7 +209,7 @@ Effacer l'exécutable sélectionné Règles actuelles - Définissez des règles par processus. Les plans d’alimentation sont mondiaux ; les masques et les priorités s'appliquent aux processus de correspondance. + Ce que ThreadPilot applique automatiquement au démarrage d'un processus. Supprimer Supprimer l'association sélectionnée Plan d'alimentation par défaut (repli lorsqu'aucune règle ne correspond) @@ -220,15 +217,12 @@ Définir le plan d'alimentation par défaut Paramètres de surveillance de l'automatisation - Activer l'automatisation basée sur les événements (WMI) - Activer l'interrogation de secours automatisée - Intervalle d'interrogation (secondes) : Délai de changement de mode d'alimentation (ms) : Empêcher les modifications du plan d'alimentation en double Enregistrer la configuration Aucune règle d'automatisation pour l'instant Statut : - Surveillance de l'automatisation + Surveillance Démarrer la surveillance de l'automatisation Arrêter la surveillance de l'automatisation @@ -237,9 +231,9 @@ Exécutable sélectionné : Descriptif : Service d'automatisation - Règles - Règles de processus enregistrées - Règles enregistrées depuis l'onglet Processus. ThreadPilot les réapplique à ce processus à chaque démarrage, tant que la surveillance automatique est active. Elles sont distinctes des règles de mode d'alimentation ci-dessus, qui changent le mode d'alimentation global de Windows. + Changement de mode d'alimentation + Règles de processus + Enregistrées depuis la page Processus. Appliquées à chaque démarrage du processus, quand la surveillance est active. Processus Applique Mode @@ -252,7 +246,7 @@ Priorité E/S Mode d'affectation CPU Empêcher la mise en veille pendant l'exécution de ce processus - Choisissez un masque CPU enregistré pour définir les cœurs de cette règle. Le sélecteur cœur par cœur se trouve dans l'onglet Processus. + Choisissez un masque CPU enregistré pour les cœurs de cette règle. Les masques se modifient dans la page Masques CPU. Enregistrer les modifications Aucune règle de processus enregistrée. Faites un clic droit sur un processus dans l'onglet Processus et choisissez Règles pour en enregistrer une. Exécutable @@ -263,8 +257,7 @@ Priorité du processus : - Gestion des processus - Rechercher, filtrer et contrôler les configurations de processus actifs + Processus Rechercher des processus par nom Recherche de processus Masquer les processus système Windows @@ -278,45 +271,25 @@ Trier par : Choisissez comment trier la liste des processus - Processus sélectionné Aucun processus sélectionné - Sélectionnez un processus dans le tableau pour activer les actions d'affinité, de priorité, de plan d'alimentation et de règle. - Plan d'alimentation/Paramètres en attente - Choisissez le plan d'alimentation à activer manuellement ou à inclure avec une application rapide - Définir le plan d'alimentation - Activer le plan d'alimentation Windows sélectionné Masque de cœurs en attente - Sélectionnez un masque à préparer. Rien ne change avant Appliquer l’affectation CPU. - Appliquer l’affectation CPU + Appliquer les cœurs Applique la sélection CPU avec le mode choisi et vérifie l’état Windows Appliquer l’affectation CPU et enregistrer la règle - En règle générale, enregistrez les paramètres de processus actuels. Ces paramètres seront automatiquement appliqués lorsque ce processus démarrera à l’avenir. Définir la priorité CPU - Appliquer la priorité sélectionnée au processus - Appliquer la priorité par le registre - Appliquez la priorité via le registre Windows. Le processus doit être redémarré pour que les modifications prennent effet. Ce paramètre persiste lors des redémarrages du système. Définir la priorité La priorité en temps réel est bloquée par ThreadPilot car elle peut rendre Windows instable ou ne pas répondre. Temps réel (bloqué) - Empêcher la veille pendant le processus - Maintient le système éveillé pendant l'exécution du processus. L'écran peut s'éteindre et une veille explicite n'est pas bloquée. - Profil / Règle - Entrez un nom de profil pour enregistrer ou charger les paramètres - Enregistrer le profil - Enregistrer l'affinité et la priorité CPU actuelles en tant que profil réutilisable - Charger le profil - Charger un profil précédemment enregistré - Enregistrer les paramètres actuels en tant que règle + Enregistrer comme règle Enregistre une règle propre au processus que ThreadPilot réapplique automatiquement au prochain démarrage de ce processus, tant que la surveillance de l'automatisation est activée. Ces règles sont distinctes de l'onglet Règles, qui gère les associations entre processus et modes d'alimentation. Supprimer la règle enregistrée Supprime la règle enregistrée pour ce processus, afin que ThreadPilot cesse de l'appliquer au démarrage du processus. Dernière action ThreadPilot : - Actualiser Actualiser la liste des processus Charger plus Charger plus de processus @@ -324,38 +297,23 @@ Charger plus de processus Liste des processus en cours d'exécution Table de processus virtualisée avec tri et sélection - La surveillance de l'automatisation est désactivée. - Activez la surveillance de l'automatisation depuis Règles et automatisation ou la zone de notification pour afficher et actualiser la liste des processus. Nom du processus Titre de la fenêtre CPU Utilisation Utilisation de la mémoire - Sélection CPU préparée dans l’interface. Elle est appliquée uniquement via Appliquer l’affectation CPU. - Affinité actuellement signalée par Windows pour le processus sélectionné Aperçu en lecture seule de la sélection CPU — modifiez-la via 'Masque de cœurs en attente'. Aperçu uniquement — ces étiquettes indiquent les CPU sélectionnés, elles ne sont pas cliquables. Choisissez un masque dans 'Masque de cœurs en attente' ci-dessus pour préparer une autre sélection (créez les masques dans l'onglet Masques CPU). Sélectionné Non sélectionné Indisponible Ouvrez l'onglet CPU Masques pour créer un nouveau masque personnalisé. - Indique si Hyper-Threading (Intel) ou SMT (AMD) est présent et actif sur ce système Copier les informations sur le processus Emplacement de l'exécutable ouvert Effacer les ensembles CPU - Appliquer les paramètres en attente - Appliquer l'affinité en attente et le plan d'alimentation sélectionné au processus sélectionné - Les règles et les modifications sont appliquées par ThreadPilot uniquement lorsqu'elles sont configurées. - Statut actuel du processus - Sélecteur d'affinité avancé Sélectionnez le masque en attente pour les actions d'affinité du menu contextuel des lignes - Plan d'alimentation sélectionné - Enregistrer comme règle - Aucun titre de fenêtre visible - Traitement par lots - total) Priorité CPU : indisponible Mémoire : indisponible @@ -386,11 +344,8 @@ Personnalisé A une fenêtre - Fenêtre - PID ID Mémoire (MB) - Affinité Priorité Règles Nom @@ -404,7 +359,7 @@ Paramètres des applications - Configurer les notifications, la barre d'état système et le comportement des applications + Notifications, zone de notification, démarrage et langue. Notifications Activer les notifications Niveau de notification @@ -433,13 +388,11 @@ Afficher des info-bulles détaillées Préférences de base - Démarrage automatique Commencez par Windows Démarrer automatiquement ThreadPilot au démarrage de Windows Mode faible impact en arrière-plan Réduisez la priorité du processus de ThreadPilot lorsqu'il est réduit ou masqué dans la barre d'état. - Apparence Utiliser un thème sombre Applique un thème global Dark/Light sur tous les onglets @@ -478,14 +431,11 @@ Signalez les bugs ou comportements inattendus via GitHub Issues. Signaler un problème À propos - Version : Copyright © 2026 PrimeBuild - Traducteur : Ylimhs Licence : AGPLv3 Vérifier les mises à jour Téléchargez et installez la mise à jour Vérifier automatiquement au démarrage - Intervalle de vérification (jours) : Inclure les mises à jour préliminaires Dernière version : Dernière vérification : @@ -565,8 +515,8 @@ Enregistrer - Ajustements et optimisations du système - Configurer les optimisations du système Windows et les ajustements de performances + Optimisations + Paramètres Windows qui influent sur les performances. Actualiser tout Actualiser tous les états de réglage Activez ce réglage Windows @@ -606,8 +556,27 @@ Mode MSI du GPU Contrôle MSI uniquement si le pilote graphique expose déjà MSISupported. Redémarrage requis. Ouvrir les paramètres - OUI - NON + 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} @@ -629,18 +598,10 @@ Échec de l'activation de {0} Échec de la désactivation de {0} Échec de la modification du système - Échec de {0} {1} Erreur lors du basculement de {0} - Erreur lors du basculement de {0} : {1} - activer - désactiver - activé - désactivé Activé Désactivé Non disponible - Chargement des modifications du système... - Les réglages du système ont été chargés avec succès Ouvrir le tableau de bord @@ -665,18 +626,6 @@ 💻 CPU : {0:F1}% | RAM : {1:F1}% | {2} - Windows a refusé cette modification. Le processus peut nécessiter des droits d'administrateur ou peut être protégé. - Le processus semble protégé par un anti-triche ou une protection de processus. ThreadPilot n'essaiera pas de le contourner. - Le mode Administrateur peut aider à résoudre les problèmes d'autorisation normaux, mais ne peut pas contourner les restrictions anti-triche ou de processus protégés. - Cette sélection CPU ne peut pas être représentée en toute sécurité par les API d'affinité héritées sur cette topologie. CPU Des ensembles sont requis pour cette sélection. - Cette sélection CPU ne correspond pas à la topologie CPU actuelle. Vérifiez ou recréez le préréglage. - Le processus s'est terminé avant que ThreadPilot puisse appliquer la modification. - Windows CPU Les ensembles ne sont pas disponibles ou ont rejeté cette sélection. ThreadPilot utilisera une solution de secours sécurisée uniquement lorsque cela est possible. - Une priorité élevée peut améliorer la réactivité de certaines charges de travail, mais peut réduire la réactivité du système. - La priorité en temps réel est bloquée par ThreadPilot car elle peut rendre Windows instable ou ne pas répondre. - La priorité persistante au moment du lancement peut être prise en charge pour les processus normaux, mais elle ne contourne pas les processus protégés ou les restrictions anti-triche. - Applique les règles enregistrées lorsqu'un processus de correspondance démarre. Certains processus protégés ou anti-triche peuvent rejeter les modifications. Le mode Administrateur peut résoudre les problèmes d'autorisation normaux, mais ne peut pas contourner la protection. - Le processus semble protégé par un anti-triche ou une protection de processus. ThreadPilot n'essaiera pas de le remplacer. Plan d'alimentation modifié @@ -728,7 +677,6 @@ Affectation du processeur Affectation CPU par défaut Choisissez comment les nouvelles actions manuelles et règles affectent les processeurs sélectionnés. - Informations sur le mode d’affectation CPU Masque d’affinité Contrainte stricte. Les sélections multi-groupes sont appliquées par thread ; les nouveaux threads sont couverts si la surveillance automatique est active. Processeur idéal @@ -747,20 +695,8 @@ Ce mode préparé suit la règle correspondante si elle existe ; sinon il utilise la valeur globale par défaut. - ThreadPilot nécessite des privilèges d'administrateur pour gérer l'affinité des processus et les plans d'alimentation. Souhaitez-vous redémarrer l'application avec les privilèges d'administrateur ? - Privilèges d'administrateur requis - Échec du redémarrage avec les privilèges d'administrateur. Veuillez exécuter manuellement ThreadPilot en tant qu'administrateur. - Échec de l'élévation - Exécution avec les privilèges d'administrateur - Fonctionner avec des privilèges limités - Impossible d'interroger la valeur Core Parking via powercfg - Impossible d'interroger la valeur C-States via powercfg - Clé de registre Prefetch introuvable - Power Throttling non disponible sur ce système - Clé de registre PriorityControl introuvable - Clé de registre de bureau introuvable Règles dans l'ordre d'application : {0}. En cas de chevauchement, la dernière l'emporte. @@ -772,7 +708,7 @@ Appliquer Automatisation en pause. Les modifications manuelles fonctionnent toujours ; les règles enregistrées ne s'appliquent qu'à la reprise de la surveillance. - Annuler la dernière modification de priorité + Annuler la dernière modification processus affichés Modifications non enregistrées Priorité précédente restaurée. @@ -787,4 +723,20 @@ Mode efficacité : {0} Mode efficacité indisponible Mode efficacité : {0} (sans effet : la limitation d'alimentation est désactivée dans les Optimisations) + + + Filtres + Colonnes + Priorité + Cœurs CPU + Règle + CPU + Mémoire + E/S + Efficacité + Sélectionnez un processus pour voir et modifier sa priorité, ses cœurs et sa règle. + Non défini + garde le PC éveillé + rien + (sans nom) diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index 095b2ae..edd0868 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -15,12 +15,12 @@ Richiedi elevazione - Gestione dei processi + Processi Maschere CPU - Piani di alimentazione + Piani energetici Regole Diagnostica - Attività di ThreadPilot + Attività Ottimizzazioni Impostazioni @@ -42,8 +42,8 @@ Overlay di caricamento all'avvio dell'applicazione - ThreadPilot Attività - Mostra le azioni eseguite da ThreadPilot, incluse regole applicate, modifiche di affinità, modifiche di priorità, modifiche del piano di alimentazione, modifiche delle impostazioni, modifiche, ottimizzazioni e messaggi di errore sicuro. + Attività + Cosa ha cambiato ThreadPilot e cosa non è riuscito. Cerca: Categoria: Livello: @@ -71,7 +71,6 @@ Dettagli attività Timestamp: Stato: - Categoria: Messaggio: Dettagli: ID di correlazione: @@ -85,8 +84,7 @@ Maschere CPU - Crea preimpostazioni di affinità CPU riutilizzabili per l'utilizzo in ciascun processo. - Selezionando una maschera qui si modifica solo la preimpostazione. Non modifica l'affinità CPU finché non lo applichi a un processo o lo salvi in ​​una regola di processo. + Selezioni di core riutilizzabili per processi e regole. Nuovo Elimina Duplica maschera @@ -119,7 +117,6 @@ Processi attivi Principali CPU e consumatori di memoria con la creazione di regole con un clic. - Cerca processi per nome Solo supportato da regole Solo utilizzabile Impatto delle regole @@ -163,7 +160,7 @@ Piani di alimentazione - Gestisci il piano di alimentazione Windows attivo e importa profili .pow personalizzati + Cambia il piano Windows attivo o importa file .pow. Windows Piani di alimentazione Seleziona il piano Windows da rendere attivo. Imposta come piano Windows attivo @@ -182,7 +179,7 @@ Attivo - Regole e automazione + Regole Il monitoraggio dell'automazione controlla gli eventi di avvio/arresto del processo e applica il piano di risparmio energia configurato, la maschera CPU e le regole di priorità. Editor delle regole Crea una regola da un processo eseguibile o in esecuzione per automatizzare il piano di risparmio energia, la maschera CPU e il comportamento prioritario. @@ -212,7 +209,7 @@ Cancella l'eseguibile selezionato Regole attuali - Definisci regole per processo. I piani di alimentazione sono globali; maschere e priorità si applicano ai processi corrispondenti. + Cosa applica ThreadPilot in automatico all'avvio di un processo. Rimuovi Rimuovi l'associazione selezionata Piano di alimentazione predefinito (fallback quando nessuna regola corrisponde) @@ -220,15 +217,12 @@ Imposta il piano di alimentazione predefinito Impostazioni di monitoraggio dell'automazione - Abilita automazione basata su eventi (WMI) - Abilita il polling di fallback dell'automazione - Intervallo di polling (secondi): Ritardo modifica piano di alimentazione (ms): Previeni modifiche duplicate del piano di alimentazione Salva configurazione Nessuna regola di automazione ancora Stato: - Monitoraggio dell'automazione + Monitoraggio Avvia il monitoraggio dell'automazione Arresta il monitoraggio dell'automazione @@ -237,9 +231,9 @@ Eseguibile selezionato: Descrizione: Servizio di automazione - Regole - Regole di processo salvate - Regole salvate dalla scheda Processi. ThreadPilot le riapplica a quel processo a ogni avvio, se il monitoraggio automatico è attivo. Sono separate dalle regole dei profili energetici qui sopra, che cambiano il piano di alimentazione globale di Windows. + Cambio piano energetico + Regole dei processi + Salvate dalla pagina Processi. Applicate a ogni avvio del processo, con il monitoraggio attivo. Processo Applica Modalità @@ -252,7 +246,7 @@ Priorità I/O Modalità di assegnazione CPU Impedisci la sospensione mentre il processo è in esecuzione - Scegli una maschera CPU salvata per impostare i core a cui questa regola vincola il processo. Il selettore core per core è nella scheda Processi. + Scegli una maschera CPU salvata per i core di questa regola. Le maschere si modificano nella pagina Maschere CPU. Salva modifiche Nessuna regola di processo salvata. Fai clic destro su un processo nella scheda Processi e scegli Regole per salvarne una. Eseguibile @@ -263,8 +257,7 @@ Priorità del processo: - Gestione dei processi - Cerca, filtra e controlla le configurazioni dei processi attivi + Processi Cerca processi per nome Ricerca di processi Nascondi i processi di sistema Windows @@ -278,45 +271,25 @@ Ordina per: Scegli come ordinare l'elenco dei processi - Processo selezionato Nessun processo selezionato - Seleziona un processo dalla tabella per abilitare le azioni di affinità, priorità, piano di risparmio energia e regole. - Piano di alimentazione/Impostazioni in sospeso - Scegli il piano di risparmio energia da attivare manualmente o da includere con l'applicazione rapida - Imposta il piano di risparmio energia - Attiva il piano di risparmio energia Windows selezionato Maschera core in sospeso - Seleziona una maschera da preparare. Nulla cambia finché non fai clic su Applica assegnazione CPU. - Applica assegnazione CPU + Applica core Applica la selezione CPU con la modalità scelta e verifica lo stato di Windows Applica assegnazione CPU e salva come regola - Salva le impostazioni del processo corrente come regola. Queste impostazioni verranno applicate automaticamente all'avvio di questo processo in futuro. Imposta la priorità CPU - Applica la priorità selezionata al processo - Applicare la priorità tramite il registro - Applica la priorità tramite il registro Windows. Il processo deve essere riavviato affinché le modifiche abbiano effetto. Questa impostazione persiste durante i riavvii del sistema. Imposta la priorità La priorità in tempo reale è bloccata da ThreadPilot perché può rendere Windows instabile o non rispondere. In tempo reale (bloccato) - Impedisci sospensione durante il processo - Mantiene attivo il sistema finché il processo è in esecuzione. Lo schermo può spegnersi e la sospensione richiesta dall'utente non viene bloccata. - Profilo/Regola - Inserisci un nome di profilo per salvare o caricare le impostazioni - Salva profilo - Salva l'affinità e la priorità CPU correnti come profilo riutilizzabile - Carica profilo - Carica un profilo salvato in precedenza - Salva le impostazioni correnti come regola + Salva come regola Salva una regola per processo che ThreadPilot riapplica automaticamente al successivo avvio di questo processo, finché il monitoraggio dell'automazione è attivo. Queste regole sono separate dalla scheda Regole, che gestisce le associazioni tra processi e combinazioni di risparmio energia. Elimina la regola salvata Rimuove la regola salvata per questo processo, così ThreadPilot smette di riapplicarla all'avvio del processo. Ultima azione ThreadPilot: - Aggiorna Aggiorna l'elenco dei processi Carica altro Carica più processi @@ -324,38 +297,23 @@ Carica più processi Elenco dei processi in esecuzione Tabella dei processi virtualizzata con ordinamento e selezione - Il monitoraggio dell'automazione è disabilitato. - Abilita il monitoraggio dell'automazione da Regole e automazione o dall'area di notifica per visualizzare e aggiornare l'elenco dei processi. Nome del processo Titolo della finestra Utilizzo CPU Utilizzo della memoria - Selezione CPU preparata nell’interfaccia. Viene applicata solo tramite Applica assegnazione CPU. - Affinità attualmente segnalata da Windows per il processo selezionato Anteprima di sola lettura della selezione CPU — scegli una maschera in 'Maschera core in sospeso' per modificarla. Solo anteprima — queste etichette mostrano quali CPU sono selezionate, non sono cliccabili. Scegli una maschera in 'Maschera core in sospeso' qui sopra per preparare una selezione diversa (le maschere si creano nella scheda Maschere CPU). Selezionata Non selezionata Non disponibile Apri la scheda CPU Maschere per creare una nuova maschera personalizzata - Mostra se Hyper-Threading (Intel) o SMT (AMD) è presente e attivo su questo sistema Copia informazioni sul processo Apri la posizione dell'eseguibile Cancella CPU Imposta - Applica le impostazioni in sospeso - Applica l'affinità in sospeso e la combinazione per il risparmio di energia selezionata al processo selezionato - Le regole e le modifiche vengono applicate da ThreadPilot solo se configurate. - Stato attuale del processo - Selettore di affinità avanzato Seleziona la maschera in sospeso per le azioni di affinità del menu contestuale delle righe - Piano di alimentazione selezionato - Salva come regola - Nessun titolo della finestra visibile - Batch - totale) Priorità CPU: non disponibile Memoria: non disponibile @@ -386,11 +344,8 @@ Personalizzato Ha finestra - Finestra - PID ID Memoria (MB) - Affinità Priorità Regole Nome @@ -404,7 +359,7 @@ Impostazioni dell'applicazione - Configura notifiche, barra delle applicazioni e comportamento dell'applicazione + Notifiche, area di notifica, avvio e lingua. Notifiche Abilita notifiche Livello di notifica @@ -433,13 +388,11 @@ Mostra descrizioni comandi dettagliate Preferenze di base - Avvio automatico Inizia con Windows Avvia automaticamente ThreadPilot all'avvio di Windows Modalità a basso impatto in background Diminuisce la priorità del processo di ThreadPilot mentre è ridotto a icona o nascosto nel vassoio. - Aspetto Utilizza il tema scuro Applica un tema globale Scuro/Chiaro a tutte le schede @@ -478,14 +431,11 @@ Segnala bug o comportamenti anomali tramite GitHub Issues. Segnala un problema Informazioni - Versione: Copyright © 2026 PrimeBuild - Traduttore: Ylimhs Licenza: AGPLv3 Controlla gli aggiornamenti Scarica e installa l'aggiornamento Controlla automaticamente all'avvio - Intervallo di controllo (giorni): Includere aggiornamenti preliminari Ultima versione: Ultimo controllo: @@ -565,8 +515,8 @@ Salva - Modifiche e ottimizzazioni del sistema - Configura Windows ottimizzazioni del sistema e modifiche alle prestazioni + Ottimizzazioni + Impostazioni di Windows che influiscono sulle prestazioni. Aggiorna tutto Aggiorna tutti gli stati delle modifiche Attiva/disattiva questo tweak Windows @@ -606,8 +556,27 @@ Modalità MSI GPU Controlla MSI solo se il driver video espone già MSISupported. È necessario riavviare. Apri impostazioni - ON - OFF + 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} @@ -629,18 +598,10 @@ Impossibile abilitare {0} Impossibile disattivare {0} Modifica del sistema non riuscita - Impossibile {0} {1} Errore durante l'attivazione/disattivazione di {0} - Errore durante l'attivazione/disattivazione di {0}: {1} - abilitare - disabilitare - abilitato - disabilitato Abilitato Disabilitato Non disponibile - Caricamento modifiche al sistema... - Le modifiche al sistema sono state caricate correttamente Apri Dashboard @@ -665,18 +626,6 @@ 💻 CPU: {0:F1}% | RAM: {1:F1}% | {2} - Windows ha negato questa modifica. Il processo potrebbe richiedere diritti di amministratore o potrebbe essere protetto. - Il processo appare protetto da anti-cheat o protezione del processo. ThreadPilot non tenterà di aggirarlo. - La modalità amministratore può aiutare con i normali problemi di autorizzazione, ma non può aggirare le restrizioni anti-cheat o sui processi protetti. - Questa selezione CPU non può essere rappresentata in modo sicuro dalle API di affinità legacy su questa topologia. CPU Per questa selezione sono necessari i set. - Questa selezione CPU non corrisponde alla topologia CPU corrente. Rivedi o ricrea la preimpostazione. - Il processo è terminato prima che ThreadPilot potesse applicare la modifica. - Windows CPU I set non sono disponibili o hanno rifiutato questa selezione. ThreadPilot utilizzerà un fallback sicuro solo quando possibile. - Una priorità elevata può migliorare la reattività per alcuni carichi di lavoro ma può ridurre la reattività del sistema. - La priorità in tempo reale è bloccata da ThreadPilot perché può rendere Windows instabile o non rispondere. - La priorità persistente al momento del lancio può essere supportata per i processi normali, ma non ignora i processi protetti o le restrizioni anti-cheat. - Applica le regole salvate all'avvio di un processo di corrispondenza. Alcuni processi protetti o anti-cheat potrebbero rifiutare le modifiche. La modalità amministratore può aiutare con i normali problemi di autorizzazione ma non può aggirare la protezione. - Il processo appare protetto da anti-cheat o protezione del processo. ThreadPilot non tenterà di sovrascriverlo. Piano di alimentazione modificato @@ -728,7 +677,6 @@ Assegnazione CPU Assegnazione CPU predefinita Scegli come le nuove azioni manuali e le regole assegnano le CPU selezionate. - Informazioni sulla modalità di assegnazione CPU Maschera di affinità Vincolo rigido. Le selezioni multi-group vengono applicate per thread; i nuovi thread sono coperti quando il monitoraggio automatico è attivo. Processore ideale @@ -747,20 +695,8 @@ Questa modalità in sospeso segue la regola corrispondente, se presente; altrimenti usa il valore predefinito globale. - ThreadPilot richiede privilegi di amministratore per gestire l'affinità dei processi e i piani di alimentazione. Desideri riavviare l'applicazione con privilegi di amministratore? - Privilegi di amministratore richiesti - Impossibile riavviare con i privilegi di amministratore. Esegui manualmente ThreadPilot come amministratore. - Elevazione non riuscita - In esecuzione con privilegi di amministratore - In esecuzione con privilegi limitati - Impossibile eseguire la query del valore Core Parking tramite powercfg - Impossibile eseguire la query del valore C-States tramite powercfg - Chiave di registro Prefetch non trovata - Power Throttling non disponibile su questo sistema - Chiave di registro PriorityControl non trovata - Chiave di registro del desktop non trovata Regole in ordine di applicazione: {0}. Per le impostazioni sovrapposte vale l'ultima. @@ -772,7 +708,7 @@ Applica Automazione in pausa. Le modifiche manuali funzionano; le regole salvate non vengono applicate finché il monitoraggio non riprende. - Annulla ultima modifica di priorità + Annulla ultima modifica processi visibili Modifiche non salvate Priorità precedente ripristinata. @@ -787,4 +723,20 @@ Modalità efficienza: {0} Modalità efficienza non disponibile Modalità efficienza: {0} (nessun effetto: il power throttling è disattivato nelle Ottimizzazioni) + + + Filtri + Colonne + Priorità + Core CPU + Regola + CPU + Memoria + I/O + Efficienza + Seleziona un processo per vedere e cambiare priorità, core e regola. + Non impostata + tiene acceso il PC + niente + (senza nome) diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 1727c2e..e9f4bbd 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -15,12 +15,12 @@ Запросить повышение - Управление процессами + Процессы Маски CPU - Планы электропитания + Электропитание Правила Диагностика - Активность ThreadPilot + Активность Тонкая настройка Настройки @@ -42,8 +42,8 @@ Оверлей загрузки приложения при запуске - ThreadPilot Активность - Показывает действия, выполняемые ThreadPilot, включая примененные правила, изменения привязки, изменения приоритета, изменения плана электропитания, изменения настроек, настройки, оптимизации и сообщения о безопасных сбоях. + Активность + Что изменил ThreadPilot и что не удалось. Поиск: Категория: Уровень: @@ -71,7 +71,6 @@ Детали деятельности Временная метка: Статус: - Категория: Сообщение: Подробности: Идентификатор корреляции: @@ -85,8 +84,7 @@ Маски CPU - Создавайте повторно используемые наборы привязки к CPU для отдельных процессов. - Выбор маски здесь редактирует только предустановку. Привязка CPU не изменяется до тех пор, пока вы не примените ее к процессу или не сохраните в правиле процесса. + Наборы ядер для процессов и правил. Новый Удалить Дублировать маску @@ -119,7 +117,6 @@ Активные процессы Лучшие потребители CPU и памяти с созданием правил в один клик. - Поиск процессов по названию Только на основе правил Действует только Влияние правила @@ -163,7 +160,7 @@ Планы электропитания - Управляйте активным планом электропитания Windows и импортируйте пользовательские профили .pow. + Смена активной схемы Windows или импорт файлов .pow. Windows Планы электропитания Выберите план Windows, чтобы активировать его. Установить как активный план Windows @@ -182,7 +179,7 @@ Активный - Правила и автоматизация + Правила Мониторинг автоматизации отслеживает события запуска и остановки процесса и применяет настроенный план электропитания, маску CPU и правила приоритета. Редактор правил Создайте правило из исполняемого или запущенного процесса для автоматизации плана электропитания, маски CPU и приоритетного поведения. @@ -212,7 +209,7 @@ Очистить выбранный исполняемый файл Текущие правила - Определите правила для каждого процесса. Планы электропитания являются глобальными; маски и приоритеты применяются к процессам сопоставления. + Что ThreadPilot применяет автоматически при запуске процесса. Удалить Удалить выбранную ассоциацию План электропитания по умолчанию (резервный вариант, если ни одно правило не соответствует) @@ -220,15 +217,12 @@ Установить план электропитания по умолчанию Настройки мониторинга автоматизации - Включить автоматизацию на основе событий (WMI) - Включить резервный опрос автоматизации - Интервал опроса (секунды): Задержка изменения плана электропитания (мс): Предотвращение дублирования изменений плана электропитания Сохранить конфигурацию Правил автоматизации пока нет Статус: - Автоматизация мониторинга + Мониторинг Начать мониторинг автоматизации Остановить мониторинг автоматизации @@ -237,9 +231,9 @@ Выбранный исполняемый файл: Описание: Служба автоматизации - Правила - Сохранённые правила процессов - Правила, сохранённые на вкладке «Процессы». ThreadPilot применяет их к процессу при каждом запуске, пока включён автоматический мониторинг. Они отличаются от правил схем питания выше, которые переключают глобальную схему питания Windows. + Смена схемы питания + Правила процессов + Сохраняются на странице «Процессы». Применяются при каждом запуске процесса, пока включён мониторинг. Процесс Применяет Режим @@ -252,7 +246,7 @@ Приоритет ввода-вывода Режим назначения ЦП Не давать системе засыпать, пока процесс работает - Выберите сохранённую маску ЦП, чтобы задать ядра для этого правила. Выбор отдельных ядер — на вкладке «Процессы». + Выберите сохранённую маску CPU для ядер этого правила. Маски редактируются на странице «Маски CPU». Сохранить изменения Сохранённых правил процессов пока нет. Щёлкните процесс правой кнопкой на вкладке «Процессы» и выберите «Правила». Исполняемый файл @@ -263,8 +257,7 @@ Приоритет процесса: - Управление процессами - Поиск, фильтрация и контроль активных конфигураций процессов + Процессы Поиск процессов по названию Процесс поиска Скрыть системные процессы Windows @@ -278,45 +271,25 @@ Сортировать по: Выберите способ сортировки списка процессов - Выбранный процесс Процесс не выбран - Выберите процесс в таблице, чтобы настроить привязку к CPU, приоритет, план электропитания и правила. - План электропитания/ожидающие настройки - Выберите план электропитания для активации вручную или включите его с помощью быстрого применения. - Установить план электропитания - Активируйте выбранный план электропитания Windows. Ожидающая маска ядер - Подготовьте маску. Изменения вступят в силу только после нажатия «Применить назначение ЦП». - Применить назначение ЦП + Применить ядра Применяет выбор ЦП в выбранном режиме и проверяет состояние Windows. Применить назначение ЦП и сохранить правило - Сохраните текущие настройки процесса как правило. Эти настройки будут автоматически применены при запуске этого процесса в будущем. Установить приоритет CPU - Применить выбранный приоритет к процессу - Обеспечение приоритета реестра - Примените приоритет через реестр Windows. Чтобы изменения вступили в силу, процесс необходимо перезапустить. Этот параметр сохраняется при перезагрузке системы. Установить приоритет Приоритет реального времени блокируется ThreadPilot, поскольку это может сделать Windows нестабильным или перестать отвечать на запросы. В реальном времени (заблокировано) - Не переходить в сон во время процесса - Поддерживает работу системы, пока процесс запущен. Экран может выключиться, а явный переход в сон не блокируется. - Профиль/Правило - Введите имя профиля, чтобы сохранить или загрузить настройки. - Сохранить профиль - Сохранить текущие настройки привязки к CPU и приоритета как повторно используемый профиль. - Загрузить профиль - Загрузите ранее сохраненный профиль - Сохранить текущие настройки как правило + Сохранить как правило Сохраняет правило для этого процесса, которое ThreadPilot автоматически применяет снова при следующем запуске процесса, пока включён мониторинг автоматизации. Эти правила не связаны с вкладкой Правила, где настраиваются сопоставления процессов и схем электропитания. Удалить сохранённое правило Удаляет сохранённое правило для этого процесса, чтобы ThreadPilot больше не применял его при запуске процесса. Последнее действие ThreadPilot: - Обновить Обновить список процессов Загрузить больше Загрузить больше процессов @@ -324,38 +297,23 @@ Загрузить больше процессов Список запущенных процессов Виртуализированная таблица процессов с сортировкой и выбором - Мониторинг автоматизации отключен. - Включите мониторинг автоматизации в разделе «Правила и автоматизация» или в системном трее, чтобы показать и обновлять список процессов. Имя процесса Название окна Загрузка CPU Использование памяти - Выбор ЦП подготовлен и применяется только командой «Применить назначение ЦП». - Текущая привязка выбранного процесса по данным Windows Предварительный просмотр выбора ЦП только для чтения — измените его в списке 'Ожидающая маска ядер'. Только предварительный просмотр — эти метки показывают выбранные ЦП и не нажимаются. Выберите маску в списке 'Ожидающая маска ядер' выше, чтобы подготовить другой выбор (маски создаются на вкладке Маски CPU). Выбрано Не выбрано Недоступно Откройте вкладку «Маски» CPU, чтобы создать новую пользовательскую маску. - Показывает, присутствует ли и активен ли Hyper-Threading (Intel) или SMT (AMD) в этой системе. Копировать информацию о процессе Открыть расположение исполняемого файла Очистить наборы CPU - Применить ожидающие настройки - Примените ожидающее соответствие и выбранный план электропитания к выбранному процессу. - Правила и изменения применяются ThreadPilot только после настройки. - Текущий статус процесса - Расширенный выбор привязки Выберите ожидающую маску для действий привязки контекстного меню строки. - Выбранный план электропитания - Сохранить как правило - Нет видимого заголовка окна - Пакетный - всего) Приоритет CPU: недоступен Память: недоступна @@ -386,11 +344,8 @@ Пользовательский Имеет окно - Окно - PID идентификатор Память (MB) - Привязка Приоритет Правила Имя @@ -404,7 +359,7 @@ Настройки приложения - Настройка уведомлений, панели задач и поведения приложений + Уведомления, трей, автозапуск и язык. Уведомления Включить уведомления Уровень уведомления @@ -433,13 +388,11 @@ Показать подробные подсказки Основные настройки - Автозапуск Начните с Windows Автоматически запускать ThreadPilot при запуске Windows. Режим низкого воздействия в фоновом режиме Понизьте приоритет процесса ThreadPilot, пока он свёрнут или скрыт в трее. - Внешний вид Использовать темную тему Применяет глобальную темную/светлую тему на всех вкладках. @@ -478,14 +431,11 @@ Сообщайте об ошибках и неожиданном поведении через GitHub Issues. Сообщить о проблеме О - Версия: © 2026 PrimeBuild. - Переводчик: Ylimhs Лицензия: AGPLv3 Проверьте наличие обновлений Загрузите и установите обновление Автоматическая проверка при запуске - Интервал проверки (дни): Включить предварительные обновления Последняя версия: Последняя проверка: @@ -565,8 +515,8 @@ Сохранить - Системные настройки и оптимизации - Настройте оптимизацию системы Windows и настройки производительности. + Тонкая настройка + Параметры Windows, влияющие на производительность. Обновить все Обновить все состояния настройки Переключить эту настройку Windows @@ -606,8 +556,27 @@ Режим MSI для GPU Управляет MSI, только если видеодрайвер уже предоставляет MSISupported. Требуется перезагрузка. Открыть параметры - ВКЛ - ВЫКЛ + Вкл. + Откл. + 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} @@ -629,18 +598,10 @@ Не удалось включить {0}. Не удалось отключить {0}. Настройка системы не удалась - Не удалось выполнить {0} {1}. Ошибка переключения {0} - Ошибка переключения {0}: {1} - включить - отключить - включен - отключен Включено Отключено Недоступно - Загрузка настроек системы... - Системные настройки успешно загружены Открыть панель управления @@ -665,18 +626,6 @@ 💻 CPU: {0:F1}% | RAM: {1:F1}% | {2} - Windows отклонил это изменение. Для этого процесса могут потребоваться права администратора или он может быть защищен. - Процесс выглядит защищенным античитом или защитой процесса. ThreadPilot не будет пытаться обойти его. - Режим администратора может помочь решить обычные проблемы с разрешениями, но не может обойти ограничения античитов или защищенных процессов. - Этот выбор CPU не может быть безопасно представлен устаревшими API привязки в этой топологии. CPU Для этого выбора необходимы наборы. - Этот выбор CPU не соответствует текущей топологии CPU. Просмотрите или воссоздайте предустановку. - Процесс завершился до того, как ThreadPilot смог применить изменение. - Windows CPU Наборы недоступны или этот выбор отклонен. ThreadPilot будет использовать безопасный резервный вариант только тогда, когда это возможно. - Высокий приоритет может улучшить скорость реагирования на некоторые рабочие нагрузки, но может снизить скорость реагирования системы. - Приоритет реального времени блокируется ThreadPilot, поскольку это может сделать Windows нестабильным или перестать отвечать на запросы. - Постоянный приоритет времени запуска может поддерживаться для обычных процессов, но он не обходит защищенные процессы или ограничения защиты от мошенничества. - Применяет сохраненные правила при запуске процесса сопоставления. Некоторые защищенные или античит-процессы могут отклонять изменения. Режим администратора может помочь решить обычные проблемы с разрешениями, но не может обойти защиту. - Процесс выглядит защищенным античитом или защитой процесса. ThreadPilot не будет пытаться его переопределить. План электропитания изменен @@ -728,7 +677,6 @@ Назначение ЦП Назначение ЦП по умолчанию Выберите, как новые ручные действия и правила назначают выбранные ЦП. - Сведения о режиме назначения ЦП Маска привязки Жёсткое ограничение. Выбор нескольких групп применяется к потокам; новые потоки охватываются при включённом автоматическом мониторинге. Идеальный процессор @@ -747,20 +695,8 @@ Этот подготовленный режим берётся из подходящего правила, а при его отсутствии — из глобальной настройки. - ThreadPilot требуются права администратора для управления привязкой процессов и планами электропитания. Хотите перезапустить приложение с правами администратора? - Требуются права администратора - Не удалось перезагрузить компьютер с правами администратора. Пожалуйста, запустите ThreadPilot вручную от имени администратора. - Не удалось повысить высоту - Запуск с правами администратора - Работа с ограниченными привилегиями - Не удалось запросить значение Core Parking через powercfg. - Не удалось запросить значение C-States через powercfg. - Ключ реестра Prefetch не найден - Power Throttling недоступен в этой системе - Ключ реестра PriorityControl не найден - Ключ реестра рабочего стола не найден Правила в порядке применения: {0}. При пересечении действует последнее. @@ -772,7 +708,7 @@ Применить Автоматизация приостановлена. Ручные изменения работают; сохранённые правила не применяются до возобновления мониторинга. - Отменить последнее изменение приоритета + Отменить последнее изменение процессов показано Несохранённые изменения Предыдущий приоритет восстановлен. @@ -787,4 +723,20 @@ Режим эффективности: {0} Режим эффективности недоступен Режим эффективности: {0} (не действует: регулирование мощности отключено в оптимизациях) + + + Фильтры + Столбцы + Приоритет + Ядра CPU + Правило + CPU + Память + Ввод-вывод + Эффективность + Выберите процесс, чтобы увидеть и изменить приоритет, ядра и правило. + Не задано + не даёт ПК уснуть + ничего + (без имени) diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 73de758..91d8719 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -15,14 +15,14 @@ 请求权限提升 - 进程管理 + 进程 CPU 掩码 电源计划 - 自动化规则 + 规则 性能诊断 - 活动日志 + 活动 系统微调 - 应用设置 + 设置 启动时最小化 @@ -42,8 +42,8 @@ 应用启动加载覆盖层 - ThreadPilot 活动日志 - 显示 ThreadPilot 执行的操作,包括已应用规则、关联性更改、优先级更改、电源计划更改、设置更改、调整、优化以及安全故障消息。 + 活动 + ThreadPilot 所做的更改及失败记录。 搜索: 类别: 级别: @@ -71,7 +71,6 @@ 活动详情 时间戳: 状态: - 类别: 消息: 详情: 相关性 ID: @@ -85,8 +84,7 @@ CPU 掩码 - 创建可复用的 CPU 关联性预设以供每个进程使用。 - 在此处选择掩码仅编辑预设。在将其应用于进程或保存在进程规则中之前,它不会更改 CPU 关联性。 + 可复用于进程和规则的核心选择。 新建 删除 复制掩码 @@ -119,7 +117,6 @@ 活动进程 通过一键规则创建来监控 CPU 和内存的占用大户。 - 按名称搜索进程 仅限包含规则的 仅限可操作的 规则影响 @@ -163,7 +160,7 @@ 电源计划管理 - 管理活动的 Windows 电源计划并导入自定义 .pow 配置文件 + 切换当前 Windows 电源计划或导入 .pow 文件。 Windows 电源计划 选择要激活的 Windows 电源计划。 设为活动 Windows 电源计划 @@ -182,7 +179,7 @@ 活动 - 规则与自动化 + 规则 自动化监控监视进程的启动/停止事件,并应用配置的电源计划、CPU 掩码和优先级规则。 规则编辑器 从可执行文件或运行中的进程创建规则,以实现电源计划、CPU 掩码和优先级行为的自动化。 @@ -212,7 +209,7 @@ 清除选定的可执行文件 当前规则 - 定义针对每个进程的规则。电源计划是全局的;掩码和优先级适用于匹配的进程。 + 进程启动时 ThreadPilot 自动应用的设置。 删除 删除所选关联 默认电源计划 (当无规则匹配时的后备方案) @@ -220,15 +217,12 @@ 设置默认电源计划 自动化监控设置 - 启用基于事件的自动化 (WMI) - 启用自动化后备轮询 - 轮询间隔 (秒): 电源计划切换延迟 (毫秒): 防止重复更改电源计划 保存配置 尚无自动化规则 状态: - 自动化监控 + 监控 启动自动化监控 停止自动化监控 @@ -237,9 +231,9 @@ 选定的可执行文件: 描述: 自动化服务 - 规则 - 已保存的进程规则 - 在“进程”选项卡中保存的规则。只要自动化监控处于开启状态,ThreadPilot 会在该进程每次启动时重新应用它们。它们与上方的电源计划规则不同,后者切换的是 Windows 全局电源计划。 + 电源计划切换 + 进程规则 + 从“进程”页面保存。监控开启时,每次进程启动都会应用。 进程 应用 模式 @@ -252,7 +246,7 @@ I/O 优先级 CPU 分配模式 该进程运行时阻止系统休眠 - 选择一个已保存的 CPU 掩码来设置此规则绑定的核心。逐核选择器在“进程”选项卡中。 + 为此规则的核心选择已保存的 CPU 掩码。掩码在“CPU 掩码”页面中编辑。 保存更改 尚无已保存的进程规则。在“进程”选项卡中右键点击某个进程并选择“规则”即可保存。 可执行文件 @@ -263,8 +257,7 @@ 进程优先级: - 系统进程管理 - 搜索、过滤和控制活动的进程配置 + 进程 按名称搜索进程 进程搜索 隐藏 Windows 系统进程 @@ -278,45 +271,25 @@ 排序方式: 选择如何对进程列表进行排序 - 选定的进程 未选择进程 - 从表格中选择一个进程以启用关联性、优先级、电源计划和规则操作。 - 电源计划 / 待处理设置 - 选择要手动激活的电源计划,或包含在快速应用中 - 设置电源计划 - 激活所选的 Windows 电源计划 待处理的核心掩码 - 选择并暂存掩码。点击“应用 CPU 分配”前不会发生更改。 - 应用 CPU 分配 + 应用核心 使用所选模式应用待处理 CPU 选择,并验证 Windows 状态 应用 CPU 分配并保存为规则 - 将当前进程设置保存为规则。以后此进程启动时将自动应用这些设置。 设置 CPU 优先级 - 将所选优先级应用于进程 - 通过注册表强制执行优先级 - 通过 Windows 注册表应用优先级。必须重新启动进程才能使更改生效。此设置在系统重启后仍然有效。 设置优先级 实时优先级已被 ThreadPilot 阻止,因为它可能会使 Windows 不稳定或无响应。 实时 (已阻止) - 进程运行时阻止睡眠 - 进程运行时保持系统唤醒。显示器仍可关闭,并且不会阻止用户明确请求的睡眠。 - 配置文件 / 规则 - 输入配置文件名称以保存或加载设置 - 保存配置文件 - 将当前 CPU 关联性和优先级保存为可复用的配置文件 - 加载配置文件 - 加载以前保存的配置文件 - 保存当前设置为规则 + 保存为规则 保存针对该进程的规则;只要启用了自动化监视,ThreadPilot 会在此进程下次启动时自动重新应用它。这些规则与“自动化规则”选项卡相互独立,后者管理进程与电源计划的关联。 删除已保存的规则 删除此进程的已保存规则,ThreadPilot 将不再在该进程启动时应用它。 最后一次 ThreadPilot 操作: - 刷新 刷新进程列表 加载更多 加载更多进程 @@ -324,38 +297,23 @@ 加载更多进程 运行中进程列表 支持排序和选择的虚拟化进程表 - 自动化监控已禁用。 - 请从“规则和自动化”或系统托盘启用自动化监控,以显示和刷新进程列表。 进程名称 窗口标题 CPU 使用率 内存使用 - CPU 选择已暂存,仅通过“应用 CPU 分配”应用。 - Windows 当前报告的所选进程的关联性 CPU 选择的只读预览 — 请在“待处理的核心掩码”中更改。 仅供预览 — 这些标签显示已选中的 CPU,不可点击。请在上方的“待处理的核心掩码”中选择一个掩码以准备其他选择(掩码在“CPU 掩码”选项卡中创建)。 已选中 未选中 不可用 打开 CPU 掩码选项卡以创建新的自定义掩码 - 显示此系统上是否具有并启用了超线程 (Intel) 或 SMT (AMD) 复制进程信息 打开可执行文件位置 清除 CPU 集 - 应用待处理设置 - 将待处理的关联性和选定的电源计划应用于选定的进程 - ThreadPilot 仅在配置后才应用规则和更改。 - 当前进程状态 - 高级关联性选择器 为行上下文菜单关联性操作选择待处理掩码 - 所选电源计划 - 保存为规则 - 无可见窗口标题 - 批次 - 总计) 优先级 CPU: 不可用 内存: 不可用 @@ -386,11 +344,8 @@ 自定义 有窗口 - 窗口 - PID ID 内存 (MB) - 关联性 优先级 规则 名称 @@ -404,7 +359,7 @@ ThreadPilot 系统设置 - 配置通知、系统托盘和应用程序行为 + 通知、托盘、启动和语言。 系统通知 启用应用通知 通知级别 @@ -433,13 +388,11 @@ 托盘图标显示详细鼠标提示 基本偏好设置 - 系统开机启动 随 Windows 开机启动 在 Windows 系统启动时自动开启 ThreadPilot 后台低系统影响模式 在最小化或隐藏至托盘时自动降低 ThreadPilot 的进程优先级。 - 外观主题 启用暗黑主题 在所有选项卡上全局应用 亮色/暗色 视觉风格 @@ -478,14 +431,11 @@ 通过 GitHub Issues 报告错误或异常行为。 报告问题 关于应用 - 软件版本: 版权所有 © 2026 PrimeBuild - 翻译人员:Ylimhs 软件授权: AGPLv3 检查新版本 下载并安装更新 启动时自动检查更新 - 检查间隔(天): 包含预发布版本 最新版本: 上次检查: @@ -565,8 +515,8 @@ 保存 - 系统微调与优化 - 配置 Windows 系统级优化项和性能微调选项 + 系统微调 + 影响性能的 Windows 设置。 刷新状态 刷新所有微调选项的状态 切换此 Windows 系统微调项 @@ -608,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} 的设置 @@ -629,18 +598,10 @@ 未能启用 {0} 未能禁用 {0} 系统微调失败 - 未能{0}{1} 切换 {0} 出错 - 切换 {0} 时出错: {1} - 启用 - 禁用 - 启用 - 禁用 已启用 已禁用 不可用 - 正在加载系统微调... - 系统微调已成功加载 打开主控制面板 @@ -665,18 +626,6 @@ 💻 CPU: {0:F1}% | 内存: {1:F1}% | {2} - Windows 拒绝了此更改。该进程可能需要管理员权限或受保护。 - 该进程似乎受到反作弊系统或进程保护的保护。ThreadPilot 不会尝试绕过它。 - 管理员模式可能有助于解决普通的权限问题,但无法绕过反作弊系统或受保护的进程限制。 - 在此拓扑上,此 CPU 选择无法由传统关联性 API 安全地表示。此选择需要使用 CPU 集。 - 此 CPU 选择与当前 CPU 拓扑不匹配。请检查或重新创建预设。 - 在 ThreadPilot 应用更改之前,进程已退出。 - Windows CPU 集不可用或拒绝了此选择。ThreadPilot 将仅在可能时使用 safe 后备方案。 - 高优先级可以提高某些工作负载的响应速度,但可能会降低系统整体响应速度。 - 实时优先级已被 ThreadPilot 阻止,因为它可能会使 Windows 不稳定或无响应。 - 普通进程可能支持持久的启动时优先级,但这不会绕过受保护进程或反作弊系统的限制。 - 在匹配的进程启动时应用保存的规则。某些受保护或反作弊进程可能会拒绝更改。管理员模式有助于解决普通权限问题,但无法绕过进程保护。 - 该进程似乎受到反作弊系统或进程保护的保护。ThreadPilot 不会尝试对其进行改写。 电源计划已更改 @@ -728,7 +677,6 @@ CPU 分配 默认 CPU 分配 选择新手动操作和规则如何分配所选 CPU。 - CPU 分配模式信息 关联掩码 硬性限制。多处理器组选择按线程应用;启用自动监控时会覆盖新线程。 理想处理器 @@ -747,20 +695,8 @@ 若有匹配规则,此暂存模式遵循该规则;否则使用全局默认值。 - ThreadPilot 需要管理员权限来管理进程关联性和电源计划。 您想以管理员权限重新启动应用程序吗? - 需要管理员权限 - 无法以管理员权限重新启动。请手动以管理员身份运行 ThreadPilot。 - 权限提升失败 - 以管理员权限运行中 - 以受限权限运行中 - 无法通过 powercfg 查询核心停车值 - 无法通过 powercfg 查询 C-States 值 - 未找到预取注册表键 - 此系统上不支持电源限流 - 未找到 PriorityControl 注册表键 - 未找到 Desktop 注册表键 按应用顺序的规则:{0}。设置重叠时以后者为准。 @@ -772,7 +708,7 @@ 应用 自动化已暂停。手动更改仍然有效;恢复监控之前不会应用已保存的规则。 - 撤销上次优先级更改 + 撤销上次更改 个进程已显示 未保存的更改 已恢复之前的优先级。 @@ -787,4 +723,20 @@ 效率模式:{0} 效率模式不可用 效率模式:{0}(无效:已在优化中禁用电源限制) + + + 筛选 + 列 + 优先级 + CPU 核心 + 规则 + CPU + 内存 + I/O + 效率 + 选择一个进程以查看和更改其优先级、核心和规则。 + 未设置 + 保持电脑唤醒 + 无 + (未命名) diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index c8876a3..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..."); @@ -2196,6 +2215,7 @@ private async Task NavMenuItem_ClickAsync(object sender, RoutedEventArgs e) protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { + this.SaveWindowPlacement(); if (this.isPerformingShutdown) { base.OnClosing(e); @@ -2228,6 +2248,7 @@ protected override void OnClosing(System.ComponentModel.CancelEventArgs e) protected override void OnClosed(EventArgs e) { + this.SaveWindowPlacement(); try { this.Loaded -= this.OnWindowLoaded; diff --git a/MainWindow.UiState.partial.cs b/MainWindow.UiState.partial.cs new file mode 100644 index 0000000..6946bb6 --- /dev/null +++ b/MainWindow.UiState.partial.cs @@ -0,0 +1,53 @@ +namespace ThreadPilot +{ + using System.Windows; + using ThreadPilot.Services; + + public partial class MainWindow + { + // Puts the menu and the window back the way the user left them. A position that is now + // off-screen (unplugged monitor) is pulled back by EnsureDashboardVisibleOnScreen at startup. + private void RestoreUiState() + { + var state = UiStateStore.Current; + this.RootNavigation.IsPaneOpen = state.NavigationPaneOpen; + this.RootNavigation.PaneOpened += (_, _) => UiStateStore.Update(s => s with { NavigationPaneOpen = true }); + this.RootNavigation.PaneClosed += (_, _) => UiStateStore.Update(s => s with { NavigationPaneOpen = false }); + + if (state.WindowWidth is > 0 && state.WindowHeight is > 0 && state.WindowLeft.HasValue && state.WindowTop.HasValue) + { + this.WindowStartupLocation = WindowStartupLocation.Manual; + this.Left = state.WindowLeft.Value; + this.Top = state.WindowTop.Value; + this.Width = state.WindowWidth.Value; + this.Height = state.WindowHeight.Value; + } + + if (state.WindowMaximized) + { + this.WindowState = WindowState.Maximized; + } + } + + private void SaveWindowPlacement() + { + // RestoreBounds is the normal-size rectangle even while maximized or minimized. + var bounds = this.WindowState == WindowState.Normal + ? new Rect(this.Left, this.Top, this.ActualWidth, this.ActualHeight) + : this.RestoreBounds; + if (bounds.IsEmpty || bounds.Width <= 0 || bounds.Height <= 0 || double.IsNaN(bounds.Left)) + { + return; + } + + UiStateStore.Update(s => s with + { + WindowLeft = bounds.Left, + WindowTop = bounds.Top, + WindowWidth = bounds.Width, + WindowHeight = bounds.Height, + WindowMaximized = this.WindowState == WindowState.Maximized, + }); + } + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml index 459e34f..f9cda54 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -83,8 +83,7 @@ diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 300d8e1..11ba983 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -101,6 +101,7 @@ public MainWindow( this.InitializeComponent(); System.Diagnostics.Debug.WriteLine("InitializeComponent completed"); + this.RestoreUiState(); this.ConfigureDiagnosticsNavigation(); // Initialize loading overlay 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/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/ActivityAuditService.cs b/Services/ActivityAuditService.cs index 578c88b..ee0f527 100644 --- a/Services/ActivityAuditService.cs +++ b/Services/ActivityAuditService.cs @@ -121,6 +121,7 @@ internal static class ActivityAuditCategories public const string Settings = "Settings"; public const string Tweaks = "Tweaks"; public const string Optimization = "Optimization"; + public const string GameBoost = "Game Boost"; public const string Diagnostics = "Diagnostics"; public const string Safety = "Safety"; } @@ -142,9 +143,9 @@ public static ActivityAuditEntry Map(string action, string details, string? cont private static string ResolveCategory(string action) { - if (action.StartsWith("ProcessAffinity", StringComparison.OrdinalIgnoreCase) || - action.StartsWith("ProcessCpuAssignment", StringComparison.OrdinalIgnoreCase) || - action.StartsWith("CpuSets", StringComparison.OrdinalIgnoreCase)) + if (action.StartsWith("ProcessAffinity", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("ProcessCpuAssignment", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("CpuSets", StringComparison.OrdinalIgnoreCase)) { return ActivityAuditCategories.Affinity; } @@ -185,6 +186,11 @@ private static string ResolveCategory(string action) return ActivityAuditCategories.Tweaks; } + if (action.StartsWith("GameBoost", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.GameBoost; + } + if (action.StartsWith("Optimization", StringComparison.OrdinalIgnoreCase)) { return ActivityAuditCategories.Optimization; 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/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..fe99b04 --- /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, string? executablePath, GameBoostMode mode, IReadOnlyCollection? userBackground) + { + var name = Normalize(processName); + return name.Length > 0 + && (Matches(userBackground, name, executablePath) + || (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..8ad8cbd --- /dev/null +++ b/Services/GameSessionService.cs @@ -0,0 +1,815 @@ +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 IActivityAuditService? activityAuditService; + 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, + IActivityAuditService? activityAuditService = 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.activityAuditService = activityAuditService; + + 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); + var (restored, left, failed) = await this.RestoreAllAsync().ConfigureAwait(false); + await this.AuditAsync( + failed.Count > 0 ? ActivityAuditSeverity.Warning : ActivityAuditSeverity.Success, + $"Restored {restored} change(s) left by an unexpected close", + DescribeRestore(restored, left, failed)).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("the game 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.AuditSessionStartAsync(game).ConfigureAwait(false); + await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); + } + + private async Task AuditSessionStartAsync(ProcessModel game) + { + var decision = await this.GetDecisionAsync().ConfigureAwait(false); + var mode = this.Settings.GameBoostMode == GameBoostMode.Isolation ? "Isolation" : "Automatic"; + var gameCpus = decision.GameSelection?.GlobalLogicalProcessorIndexes.Count ?? 0; + var background = decision.BackgroundSelection?.GlobalLogicalProcessorIndexes.Count ?? 0; + var policy = decision.Kind switch + { + GameBoostTopologyKind.AmdAsymmetricCache => $"AMD 3D V-Cache: game on {gameCpus} CPUs, listed background on {background} CPUs", + GameBoostTopologyKind.AmdSymmetricMultiL3 when decision.GameSelection != null => $"AMD equal CCDs: game on CCD0 ({gameCpus} CPUs), listed background on {background} CPUs", + GameBoostTopologyKind.AmdSymmetricMultiL3 => $"AMD equal CCDs: game left to Windows, listed background on {background} CPUs", + GameBoostTopologyKind.IntelHybrid => $"Intel hybrid: game on {gameCpus} P-core CPUs, listed background on {background} E-core CPUs", + _ => "CPU Sets unchanged: this CPU topology is not supported, Windows decides the cores", + }; + await this.AuditAsync(ActivityAuditSeverity.Info, $"Session started: {game.Name} ({mode})", policy).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) + { + var (outcome, reason) = await this.TryApplyCpuSetsAsync(game, currentSession.GameStartTimeUtcTicks, decision.GameSelection, rules).ConfigureAwait(false); + await this.AuditGameCpuSetsAsync(game.Name, outcome, reason, decision.GameSelection.GlobalLogicalProcessorIndexes.Count).ConfigureAwait(false); + } + + if (this.Settings.GameBoostAboveNormalPriority) + { + await this.TryApplyAboveNormalAsync(game, currentSession.GameStartTimeUtcTicks, rules).ConfigureAwait(false); + } + } + + if (decision.BackgroundSelection == null) + { + return; + } + + var moved = new List(); + var notMoved = new List(); + 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; + } + + var (outcome, reason) = await this.TryApplyCpuSetsAsync(process, startTicks, decision.BackgroundSelection, rules).ConfigureAwait(false); + switch (outcome) + { + case CpuSetOutcome.Applied: + moved.Add(process.Name); + break; + case CpuSetOutcome.UserRule: + notMoved.Add($"{process.Name} (your rule)"); + break; + case CpuSetOutcome.ExistingSelection: + notMoved.Add($"{process.Name} (already has CPU Sets)"); + break; + case CpuSetOutcome.Unreadable: + case CpuSetOutcome.Failed: + notMoved.Add($"{process.Name} ({reason})"); + break; + } + } + + // One line per pass and only when something changed, so the 15 s rescan stays quiet. + if (moved.Count > 0) + { + await this.AuditAsync( + ActivityAuditSeverity.Success, + $"Background moved to {decision.BackgroundSelection.GlobalLogicalProcessorIndexes.Count} CPUs: {Summarize(moved)}").ConfigureAwait(false); + } + + if (notMoved.Count > 0) + { + await this.AuditAsync(ActivityAuditSeverity.Info, "Background left unchanged", Summarize(notMoved)).ConfigureAwait(false); + } + } + + private Task AuditGameCpuSetsAsync(string game, CpuSetOutcome outcome, string reason, int cpuCount) => outcome switch + { + CpuSetOutcome.Applied => this.AuditAsync(ActivityAuditSeverity.Success, $"{game}: CPU Sets set to {cpuCount} CPUs"), + CpuSetOutcome.UserRule => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: cores left to your rule"), + CpuSetOutcome.ExistingSelection => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: already has CPU Sets, left unchanged"), + CpuSetOutcome.Unreadable or CpuSetOutcome.Failed => this.AuditAsync(ActivityAuditSeverity.Warning, $"{game}: CPU Sets not changed", reason), + _ => Task.CompletedTask, + }; + + private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSession) => + process.ProcessId != currentSession.Game.ProcessId + && process.ProcessId != Environment.ProcessId + && GameBoostProcessLists.IsListedBackground(process.Name, process.ExecutablePath, this.Settings.GameBoostMode, this.Settings.GameBoostBackgroundProcesses) + && !this.processFilterService.IsSystemProcess(process) + && !IsUnderDirectory(process.ExecutablePath, currentSession.GameDirectory); + + private async Task<(CpuSetOutcome Outcome, string Reason)> 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))) + { + return (CpuSetOutcome.AlreadyAttempted, string.Empty); + } + + if (this.HasUserRule(rules, process, GameBoostSetting.CpuSets)) + { + return (CpuSetOutcome.UserRule, string.Empty); + } + + // 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) + { + return (CpuSetOutcome.Unreadable, "CPU Sets unreadable: access denied or protected process"); + } + + if (current.Count > 0) + { + return (CpuSetOutcome.ExistingSelection, string.Empty); + } + + 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); + return (CpuSetOutcome.Applied, string.Empty); + } + 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); + return (CpuSetOutcome.Failed, string.IsNullOrWhiteSpace(result.UserMessage) ? "not applied" : result.UserMessage.TrimEnd('.')); + } + } + + private async Task TryApplyAboveNormalAsync(ProcessModel game, long startTicks, IReadOnlyList rules) + { + if (this.journal.Any(e => e.ProcessId == game.ProcessId && e.StartTimeUtcTicks == startTicks && e.Setting == GameBoostSetting.Priority)) + { + return; + } + + if (this.HasUserRule(rules, game, GameBoostSetting.Priority)) + { + await this.AuditAsync(ActivityAuditSeverity.Info, $"{game.Name}: priority left to your rule").ConfigureAwait(false); + return; + } + + if (game.Priority != ProcessPriorityClass.Normal) + { + await this.AuditAsync(ActivityAuditSeverity.Info, $"{game.Name}: priority left at {game.Priority}", "Game Boost only raises Normal to Above Normal").ConfigureAwait(false); + 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); + await this.AuditAsync(ActivityAuditSeverity.Success, $"{game.Name}: priority set to Above Normal").ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogInformation(ex, "Game Boost could not raise the priority of {Game}", game.Name); + await this.AuditAsync(ActivityAuditSeverity.Warning, $"{game.Name}: priority not changed", ex.Message).ConfigureAwait(false); + } + } + + private async Task EndSessionAsync(string reason) + { + if (this.session == null && this.journal.Count == 0) + { + return; + } + + var gameName = this.session?.Game.Name; + if (gameName != null) + { + this.logger.LogInformation("Game Boost session for {Game} ended: {Reason}", gameName, reason); + } + + this.session = null; + this.attempted.Clear(); + var (restored, left, failed) = await this.RestoreAllAsync().ConfigureAwait(false); + await this.AuditAsync( + failed.Count > 0 ? ActivityAuditSeverity.Warning : ActivityAuditSeverity.Info, + gameName == null ? $"Changes restored: {reason}" : $"Session ended for {gameName}: {reason}", + DescribeRestore(restored, left, failed)).ConfigureAwait(false); + } + + private async Task<(int Restored, int LeftAlone, List Failed)> RestoreAllAsync() + { + var restored = 0; + var left = 0; + var failed = new List(); + 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()) + { + RestoreOutcome outcome; + try + { + outcome = 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); + outcome = RestoreOutcome.Failed; + } + + switch (outcome) + { + case RestoreOutcome.Restored: + restored++; + break; + case RestoreOutcome.LeftAlone: + left++; + break; + default: + failed.Add($"{entry.ProcessName} {(entry.Setting == GameBoostSetting.CpuSets ? "CPU Sets" : "priority")}"); + break; + } + + this.journal.Remove(entry); + } + + await this.SaveJournalAsync().ConfigureAwait(false); + return (restored, left, failed); + } + + // 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 RestoreOutcome.LeftAlone; + } + + var process = await this.processService.GetProcessByIdAsync(entry.ProcessId).ConfigureAwait(false); + if (process == null || this.HasUserRule(rules, process, entry.Setting)) + { + return RestoreOutcome.LeftAlone; + } + + 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))) + { + return RestoreOutcome.LeftAlone; + } + + if (!await this.processService.ClearProcessCpuSetAsync(process).ConfigureAwait(false)) + { + return RestoreOutcome.Failed; + } + + this.logger.LogInformation("Game Boost restored CPU Sets of {ProcessName} (PID {ProcessId})", process.Name, process.ProcessId); + return RestoreOutcome.Restored; + } + + if (entry.OriginalPriority is not ProcessPriorityClass original || process.Priority != entry.AppliedPriority) + { + return RestoreOutcome.LeftAlone; + } + + await this.processService.SetProcessPriority(process, original, ProcessPriorityWriteSource.CleanupOrRestore).ConfigureAwait(false); + return RestoreOutcome.Restored; + } + + // "chrome x12, steam x2": the many processes of one app read as one item. + private static string Summarize(IEnumerable names) => + string.Join(", ", names + .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(group => group.Count()) + .Select(group => group.Count() > 1 ? $"{group.Key} x{group.Count()}" : group.Key)); + + private static string DescribeRestore(int restored, int left, IReadOnlyCollection failed) + { + var text = $"Restored {restored} change(s)"; + if (left > 0) + { + text += $"; {left} left as they are (changed by you or a rule since, or the process exited)"; + } + + return failed.Count > 0 ? $"{text}; could not restore: {Summarize(failed)}" : text; + } + + private Task AuditAsync(ActivityAuditSeverity severity, string message, string? details = null) + { + if (this.activityAuditService == null) + { + return Task.CompletedTask; + } + + const string category = ActivityAuditCategories.GameBoost; + return severity switch + { + ActivityAuditSeverity.Success => this.activityAuditService.LogSuccessAsync(category, message, details), + ActivityAuditSeverity.Warning => this.activityAuditService.LogWarningAsync(category, message, details), + ActivityAuditSeverity.Error => this.activityAuditService.LogErrorAsync(category, message, details), + _ => this.activityAuditService.LogInfoAsync(category, message, details), + }; + } + + 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.AuditSessionStartAsync(previous.Game).ConfigureAwait(false); + 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 enum CpuSetOutcome + { + AlreadyAttempted, + Applied, + UserRule, + ExistingSelection, + Unreadable, + Failed, + } + + private enum RestoreOutcome + { + Restored, + LeftAlone, + Failed, + } + + 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/Services/UiStateStore.cs b/Services/UiStateStore.cs new file mode 100644 index 0000000..6cf0768 --- /dev/null +++ b/Services/UiStateStore.cs @@ -0,0 +1,99 @@ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Text.Json; + + // Layout choices the user makes by hand (menu, window, process list view). Kept out of settings.json + // so they never mark the Settings page as unsaved and never travel in exported configurations. + public sealed record UiState + { + public bool NavigationPaneOpen { get; init; } = true; + + public double? WindowLeft { get; init; } + + public double? WindowTop { get; init; } + + public double? WindowWidth { get; init; } + + public double? WindowHeight { get; init; } + + public bool WindowMaximized { get; init; } + + public bool ShowActiveApplicationsOnly { get; init; } + + public bool HideSystemProcesses { get; init; } + + public bool HideIdleProcesses { get; init; } + + public string SortMode { get; init; } = "CpuUsage"; + + public bool ShowPidColumn { get; init; } = true; + + public bool ShowWindowTitleColumn { get; init; } + + public bool ShowHasWindowColumn { get; init; } + } + + public static class UiStateStore + { + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private static readonly object Gate = new(); + private static UiState? current; + + internal static string FilePath { get; set; } = Path.Combine(StoragePaths.AppDataRoot, "ui-state.json"); + + public static UiState Current + { + get + { + lock (Gate) + { + return current ??= Load(); + } + } + } + + public static void Update(Func change) + { + lock (Gate) + { + var updated = change(current ??= Load()); + if (updated == current) + { + return; + } + + current = updated; + try + { + // Synchronous on purpose: this also runs while the window closes, right before exit. + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + var temp = FilePath + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(updated, JsonOptions)); + File.Move(temp, FilePath, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Losing a layout preference is harmless; the next change tries again. + } + } + } + + internal static void ResetForTests() => current = null; + + private static UiState Load() + { + try + { + return File.Exists(FilePath) + ? JsonSerializer.Deserialize(File.ReadAllText(FilePath), JsonOptions) ?? new UiState() + : new UiState(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return new UiState(); + } + } + } +} 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/ApplicationSettingsServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs index f5aa99e..45aaa4c 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs @@ -167,6 +167,37 @@ public async Task UpdateSettingsAsync_RoundTripsDefaultCpuAssignmentMode() Assert.Equal(CpuAssignmentMode.CpuSets, reloaded.Settings.DefaultCpuAssignmentMode); } + [Fact] + public async Task UpdateSettingsAsync_EveryOptionSurvivesARestart() + { + // Flip every on/off option, save, and read it back with a fresh service: nothing may silently + // fall back to its default. Migration markers are excluded because loading rewrites them on purpose. + var storage = new FakeSettingsStorage(); + var service = CreateService(storage); + await service.LoadSettingsAsync(); + var settings = service.Settings; + var flags = typeof(ApplicationSettingsModel).GetProperties() + .Where(property => property.PropertyType == typeof(bool) && property.CanWrite && + !property.Name.StartsWith("HasMigrated", StringComparison.Ordinal)) + .ToList(); + var expected = flags.ToDictionary(property => property.Name, property => !(bool)property.GetValue(settings)!); + foreach (var property in flags) + { + property.SetValue(settings, expected[property.Name]); + } + + settings.Language = "de-DE"; + await service.UpdateSettingsAsync(settings); + + var reloaded = CreateService(storage); + await reloaded.LoadSettingsAsync(); + var lost = flags.Where(property => (bool)property.GetValue(reloaded.Settings)! != expected[property.Name]) + .Select(property => property.Name) + .ToList(); + Assert.Empty(lost); + Assert.Equal("de-DE", reloaded.Settings.Language); + } + [Fact] public async Task UpdateSettingsAsync_PersistsAutomationMonitoringDisabled() { 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/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..7fc4469 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/GameBoostProcessListsTests.cs @@ -0,0 +1,58 @@ +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", null, GameBoostMode.Automatic, userList)); + Assert.False(GameBoostProcessLists.IsListedBackground("Spotify", null, GameBoostMode.Automatic, userList)); + Assert.True(GameBoostProcessLists.IsListedBackground("Spotify", null, GameBoostMode.Isolation, userList)); + Assert.False(GameBoostProcessLists.IsListedBackground("Discord", null, GameBoostMode.Isolation, userList)); + Assert.False(GameBoostProcessLists.IsListedBackground("obs64", null, GameBoostMode.Isolation, userList)); + } + + [Fact] + public void IsListedBackground_UserAddedEntry() + { + Assert.True(GameBoostProcessLists.IsListedBackground("MyTool", null, GameBoostMode.Automatic, ["MyTool.exe"])); + Assert.True(GameBoostProcessLists.IsListedBackground("MyTool", @"C:\Tools\MyTool.exe", GameBoostMode.Automatic, [@"C:\Tools\MyTool.exe"])); + Assert.False(GameBoostProcessLists.IsListedBackground("MyTool", @"D:\Other\MyTool.exe", GameBoostMode.Automatic, [@"C:\Tools\MyTool.exe"])); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs new file mode 100644 index 0000000..4b405f5 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs @@ -0,0 +1,197 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using System.Runtime.InteropServices; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Win32.SafeHandles; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using Xunit.Abstractions; + + /// + /// Real processes, real ThreadPilot services and real CPU Set / priority calls. Only the topology is + /// simulated (this machine's CPU Set IDs split into two L3 groups), so the rollback can be exercised on + /// CPUs where Game Boost itself would not act. The children are copies of PING.EXE. + /// + public sealed class GameSessionNativeIntegrationTests(ITestOutputHelper output) : IDisposable + { + private const ulong MB = 1024UL * 1024UL; + + private readonly string root = Path.Combine(Path.GetTempPath(), $"tp-gb-{Guid.NewGuid():N}"); + private readonly List children = new(); + + public void Dispose() + { + foreach (var child in this.children) + { + try + { + child.Kill(); + child.WaitForExit(5000); + } + catch + { + // already gone + } + + child.Dispose(); + } + + try + { + Directory.Delete(this.root, recursive: true); + } + catch + { + // best effort + } + } + + [Fact] + public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() + { + var real = await new WindowsCpuTopologyProvider().GetTopologySnapshotAsync(); + var processors = real.LogicalProcessors.ToList(); + var cores = processors.Select(p => real.TryGetCoreIndex(p, out var core) ? core : -1).ToList(); + var gameCount = processors.Count * 3 / 4; + if (processors.Any(p => !real.TryGetCpuSetId(p, out _)) + || cores.Take(gameCount).Distinct().Count() < GameBoostPolicy.MinimumGamePhysicalCores + || gameCount == processors.Count) + { + output.WriteLine($"Skipped: {processors.Count} logical CPUs are too few to split into a 6-core game group."); + return; + } + + var topology = CpuTopologySnapshot.Create( + processors, + cpuSetIds: processors.ToDictionary(p => p, p => real.TryGetCpuSetId(p, out var id) ? id : 0U), + coreIndexes: processors.Select((p, i) => (p, i)).ToDictionary(x => x.p, x => cores[x.i]), + lastLevelCacheIndexes: processors.Select((p, i) => (p, i)).ToDictionary(x => x.p, x => x.i < gameCount ? 0 : 1), + lastLevelCacheSizes: new Dictionary { [0] = 96 * MB, [1] = 32 * MB }); + var gameCpus = Enumerable.Range(0, gameCount).Select(i => processors[i].GlobalIndex).ToList(); + var backgroundCpus = processors.Skip(gameCount).Select(p => p.GlobalIndex).ToList(); + + var game = this.StartCopyOfPing(Path.Combine(this.root, "steamapps", "common", "FakeGame"), "FakeGame.exe"); + var browserPath = Path.Combine(this.root, "apps", "chrome.exe"); + var browser = this.StartCopyOfPing(Path.GetDirectoryName(browserPath)!, "chrome.exe"); + var journal = Path.Combine(this.root, "journal.json"); + // The exact path, so a real browser running on this machine is never touched. + var settings = new ApplicationSettingsModel + { + GameBoostEnabled = true, + GameBoostAboveNormalPriority = true, + GameBoostBackgroundProcesses = [browserPath], + }; + + // 1. Session: real CPU Sets and priority on both children. + var first = this.CreateService(topology, settings, game.Id, journal); + await first.TickAsync(); + Assert.Equal(gameCpus, ReadDefaultCpuSetIndexes(game, topology)); + Assert.Equal(backgroundCpus, ReadDefaultCpuSetIndexes(browser, topology)); + Assert.Equal(ProcessPriorityClass.AboveNormal, ReadPriority(game)); + Assert.True(File.Exists(journal)); + + // 2. Turned off: everything restored without a restart. + settings.GameBoostEnabled = false; + await first.ReapplyAsync(); + Assert.Empty(ReadDefaultCpuSetIndexes(game, topology)); + Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); + Assert.Equal(ProcessPriorityClass.Normal, ReadPriority(game)); + Assert.False(File.Exists(journal)); + + // 3. On again, then a "crash": the service is dropped without StopAsync. + settings.GameBoostEnabled = true; + await first.TickAsync(); + Assert.Equal(backgroundCpus, ReadDefaultCpuSetIndexes(browser, topology)); + first.Dispose(); + + // 4. Next start reads the journal and undoes the crashed run. + using var second = this.CreateService(topology, new ApplicationSettingsModel(), game.Id, journal); + await second.StartAsync(); + Assert.Empty(ReadDefaultCpuSetIndexes(game, topology)); + Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); + Assert.Equal(ProcessPriorityClass.Normal, ReadPriority(game)); + Assert.False(File.Exists(journal)); + await second.StopAsync(); + } + + private GameSessionService CreateService(CpuTopologySnapshot topology, ApplicationSettingsModel settings, int gamePid, string journal) + { + var settingsService = new Mock(); + settingsService.SetupGet(s => s.Settings).Returns(settings); + var topologyProvider = new Mock(); + topologyProvider.Setup(p => p.GetTopologySnapshotAsync(It.IsAny())).ReturnsAsync(topology); + var cpuTopologyService = new Mock(); + cpuTopologyService.Setup(s => s.DetectTopologyAsync()).ReturnsAsync(new CpuTopologyModel { CpuBrand = "AMD (simulated dual L3)" }); + var foreground = new Mock(); + foreground.Setup(f => f.TryGetForegroundProcessId()).Returns(gamePid); + var rules = new Mock(); + rules.Setup(s => s.LoadAsync()).ReturnsAsync(Array.Empty()); + + var processService = new ProcessService(NullLogger.Instance); + var affinityService = new AffinityApplyService( + processService, + new CpuTopologyService(NullLogger.Instance), + NullLogger.Instance); + + return new GameSessionService( + settingsService.Object, + topologyProvider.Object, + cpuTopologyService.Object, + foreground.Object, + processService, + affinityService, + rules.Object, + new PersistentProcessRuleMatcher(), + new ProcessFilterService(), + new ProcessPolicyApplyGate(), + new GameBoostProcessProbe(), + NullLogger.Instance, + journal); + } + + private Process StartCopyOfPing(string directory, string fileName) + { + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, fileName); + File.Copy(Path.Combine(Environment.SystemDirectory, "PING.EXE"), path); + var process = Process.Start(new ProcessStartInfo(path, "-n 120 127.0.0.1") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + })!; + this.children.Add(process); + return process; + } + + private static ProcessPriorityClass ReadPriority(Process process) + { + process.Refresh(); + return process.PriorityClass; + } + + // Read straight from Windows, not through ThreadPilot's handler. + private static List ReadDefaultCpuSetIndexes(Process process, CpuTopologySnapshot topology) + { + using var handle = OpenProcess(0x1000, false, process.Id); // PROCESS_QUERY_LIMITED_INFORMATION + Assert.False(handle.IsInvalid); + GetProcessDefaultCpuSets(handle, null, 0, out var count); + var ids = new uint[count]; + if (count > 0) + { + Assert.True(GetProcessDefaultCpuSets(handle, ids, count, out count)); + } + + var byId = topology.LogicalProcessors.ToDictionary(p => topology.TryGetCpuSetId(p, out var id) ? id : 0U, p => p.GlobalIndex); + return ids.Take((int)count).Select(id => byId[id]).OrderBy(i => i).ToList(); + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern SafeProcessHandle OpenProcess(uint access, bool inherit, int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount, out uint requiredIdCount); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs new file mode 100644 index 0000000..8eded2a --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -0,0 +1,430 @@ +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 readonly ActivityAuditService audit = new(NullLogger.Instance); + 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]); + } + + [Fact] + public async Task Activity_ShowsSessionChangesAndRestore_WithoutRepeatingOnRescans() + { + this.processes.Add(201, "chrome", @"C:\Program Files\Google\Chrome\Application\chrome.exe"); + this.rules.Add(new PersistentProcessRule { IsEnabled = true, ProcessName = "steam", LegacyAffinityMask = 0xF }); + this.processes.Add(202, "steam", @"C:\Program Files (x86)\Steam\steam.exe"); + using var service = this.CreateService(); + + await service.TickAsync(); + for (var i = 0; i < 10; i++) + { + await service.TickAsync(); // background rescans with nothing new + } + + this.processes.Exit(GamePid); + await service.TickAsync(); + + var entries = (await this.audit.GetEntriesAsync()).Reverse().ToList(); + Assert.All(entries, entry => Assert.Equal("Game Boost", entry.Category)); + Assert.Equal( + [ + "Session started: Game (Automatic)", + "Game: CPU Sets set to 16 CPUs", + "Background moved to 16 CPUs: chrome x2", + "Background left unchanged", + "Session ended for Game: the game exited", + ], + entries.Select(entry => entry.Message)); + Assert.Contains("AMD 3D V-Cache", entries[0].Details, StringComparison.Ordinal); + Assert.Equal("steam (your rule)", entries[3].Details); + Assert.Equal("Restored 2 change(s); 1 left as they are (changed by you or a rule since, or the process exited)", entries[4].Details); + } + + [Fact] + public async Task Activity_CrashRecoveryAndSkippedPriority() + { + this.processes.Priorities[GamePid] = ProcessPriorityClass.BelowNormal; + this.settings.GameBoostAboveNormalPriority = true; + using (var crashed = this.CreateService()) + { + await crashed.TickAsync(); + } + + using var service = this.CreateService(); + await service.StartAsync(); + await service.StopAsync(); + + var messages = (await this.audit.GetEntriesAsync()).Select(entry => entry.Message).ToList(); + Assert.Contains("Game: priority left at BelowNormal", messages); + Assert.Contains("Restored 2 change(s) left by an unexpected close", messages); + } + + 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, + this.audit); + } + + /// 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/MasksViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/MasksViewModelTests.cs index 5f0ea58..fa2e64d 100644 --- a/Tests/ThreadPilot.Core.Tests/MasksViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/MasksViewModelTests.cs @@ -17,7 +17,7 @@ public void MasksView_SubtitleClarifiesPerProcessUse() var locale = LoadEnglishLocale(); Assert.Contains("MasksView_Subtitle", serialized, StringComparison.Ordinal); - Assert.Contains("per-process use", locale, StringComparison.Ordinal); + Assert.Contains("for processes and rules", locale, StringComparison.Ordinal); } [Fact] diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index 0159143..f5683a6 100644 --- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs @@ -4,8 +4,8 @@ namespace ThreadPilot.Core.Tests public sealed partial class PackagingMetadataTests { - private const string ReleaseVersion = "1.7.6"; - private const string ReleaseAssemblyVersion = "1.7.6.0"; + private const string ReleaseVersion = "1.7.7-beta.2"; + private const string ReleaseAssemblyVersion = "1.7.7.0"; [Fact] public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata() @@ -97,6 +97,21 @@ public void VersionMetadata_IsBumpedToReleaseVersion() AssertFileContains(Path.Combine(root, "docs", "release", "RELEASE_NOTES.md"), $"v{ReleaseVersion}"); } + [Fact] + public void PrereleaseWorkflow_KeepsBetaOutOfStableChannelsAndUsesTesterNotes() + { + var root = FindRepositoryRoot(); + var workflow = File.ReadAllText(Path.Combine(root, ".github", "workflows", "release.yml")); + Assert.Contains("^v\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$", workflow, StringComparison.Ordinal); + Assert.Contains("runs-on: windows-2025", workflow, StringComparison.Ordinal); + AssertFileContains(Path.Combine(root, ".github", "workflows", "ci-devsecops.yml"), "runs-on: windows-2025"); + Assert.Contains("prerelease: ${{ needs.build.outputs.prerelease == 'true' }}", workflow, StringComparison.Ordinal); + Assert.Contains("make_latest: ${{ needs.build.outputs.prerelease == 'true' && 'false' || 'true' }}", workflow, StringComparison.Ordinal); + Assert.Contains("if: needs.release.result == 'success' && needs.build.outputs.prerelease != 'true'", workflow, StringComparison.Ordinal); + Assert.Contains("cp \"docs/releases/${{ needs.build.outputs.tag }}.md\" release-notes.md", workflow, StringComparison.Ordinal); + Assert.True(File.Exists(Path.Combine(root, "docs", "releases", $"v{ReleaseVersion}.md"))); + } + private static void AssertFileContains(string path, string expected) { var content = File.ReadAllText(path); @@ -120,7 +135,7 @@ private static string FindRepositoryRoot() throw new InvalidOperationException("Repository root could not be located."); } - [GeneratedRegex("#define MyAppVersion \"1\\.7\\.6\"", RegexOptions.CultureInvariant)] + [GeneratedRegex("#define MyAppVersion \"1\\.7\\.7-beta\\.2\"", RegexOptions.CultureInvariant)] private static partial Regex MyAppVersionRegex(); } } 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/ProcessPowerPlanAssociationViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationViewModelTests.cs index b47143c..d4f4017 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationViewModelTests.cs @@ -55,8 +55,8 @@ public async Task RefreshSavedProcessRulesAsync_ListsWhatEachRuleActuallyApplies var row = Assert.Single(viewModel.SavedProcessRules); Assert.Equal("cs2", row.ProcessName); - Assert.Contains("CPU assignment", row.Applies); - Assert.Contains("memory Low", row.Applies); + Assert.Contains("CPU cores", row.Applies); + Assert.Contains("Memory Low", row.Applies); Assert.Equal("AffinityMask", row.Mode); Assert.True(viewModel.HasSavedProcessRules); } diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 0bfb9ac..0b026a7 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -264,6 +264,46 @@ public async Task ContextMemoryPriorityCommand_WhenSuccessful_UpdatesSelectedPro Assert.Equal("Memory priority: BelowNormal", viewModel.SelectedProcessSummary.MemoryPriorityText); } + [Fact] + public async Task PriorityPickers_StartFromTheSelectedProcessCurrentValues() + { + var memoryPriorityService = new Mock(); + memoryPriorityService + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.Low); + memoryPriorityService + .Setup(service => service.GetIoPriorityAsync(It.IsAny())) + .ReturnsAsync((ProcessIoPriority?)null); + var viewModel = CreateViewModel(CreateProcessService().Object, memoryPriorityService: memoryPriorityService.Object); + var process = CreateProcess(); + process.Priority = ProcessPriorityClass.High; + + await viewModel.SelectedProcessSummary.UpdateAsync(process); + + Assert.Equal(ProcessPriorityClass.High, viewModel.PendingCpuPriority); + Assert.Equal(ProcessMemoryPriority.Low, viewModel.PendingMemoryPriority); + Assert.Null(viewModel.PendingIoPriority); // unreadable: the picker stays empty instead of guessing + } + + [Fact] + public void ProcessCounter_NeverShowsMoreVisibleThanTotal() + { + var viewModel = CreateViewModel(CreateProcessService().Object); + viewModel.TotalProcessCount = 1; // stale snapshot from an earlier batch load + foreach (var pid in new[] { 101, 102, 103 }) + { + viewModel.Processes.Add(new ProcessModel { ProcessId = pid, Name = $"p{pid}" }); + } + + // Filtering is debounced through the dispatcher in the app; call the filter step directly. + typeof(ProcessViewModel) + .GetMethod("FilterProcesses", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(viewModel, null); + + Assert.Equal(3, viewModel.FilteredProcesses.Count); + Assert.Equal(3, viewModel.TotalProcessCount); + } + [Fact] public async Task CopyContextProcessInfo_IncludesNamePidAndPath() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index a2373fe..90c08db 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -301,15 +301,14 @@ public void LegacyActionSidePanel_IsGoneAndItsControlsLiveInTheVisiblePicker() var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); var serialized = document.ToString(SaveOptions.DisableFormatting); + // The core picker holds the primary apply action, so it is a plain card, never collapsed. var picker = Assert.Single( document.Descendants(), - element => element.Name.LocalName == "Expander" && - element.Attributes().Any(attribute => - attribute.Value.Contains("ProcessView_AdvancedAffinityPicker", StringComparison.Ordinal))); + element => element.Attributes().Any(attribute => + attribute.Name.LocalName == "Name" && attribute.Value == "CpuCoresCard")); var pickerXml = picker.ToString(SaveOptions.DisableFormatting); - // The picker now holds the primary apply action, so it must start open. - Assert.Equal("True", picker.Attribute("IsExpanded")?.Value); + Assert.DoesNotContain(picker.AncestorsAndSelf(), element => element.Name.LocalName == "Expander"); Assert.Contains("SelectedCpuAssignmentMode", pickerXml, StringComparison.Ordinal); Assert.Contains("SetAffinityCommand", pickerXml, StringComparison.Ordinal); Assert.Contains("ProcessView_CpuCoresReadOnlyHint", pickerXml, StringComparison.Ordinal); 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/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs index 09934bb..584edfe 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs @@ -17,44 +17,30 @@ public void Startup_LoadsPersistedTweakStateBeforePageNavigation() } [Fact] - public void TweaksView_UsesLabelledPillToggle() + public void TweaksView_UsesWindows11ToggleWithStateLabel() { - // The native ui:ToggleSwitch is 40x20 with no text, too quiet to read as a control on - // this list. The pill states ON/OFF inside the track and is sized to be obvious. + // Native Windows 11 switch (40x20 track, sliding thumb) with "On"/"Off" written beside it, + // as in Windows Settings, so the state is readable without decoding colours. var document = XDocument.Load(GetRepositoryFilePath("Views", "SystemTweaksView.xaml")); var serialized = document.ToString(SaveOptions.DisableFormatting); - Assert.Contains("PillToggleButtonStyle", serialized, StringComparison.Ordinal); + Assert.Contains("Win11ToggleSwitchStyle", serialized, StringComparison.Ordinal); Assert.Contains("AutomationProperties.Name=\"{Binding Name}\"", serialized, StringComparison.Ordinal); Assert.Contains("SystemTweaks_ToggleOn", serialized, StringComparison.Ordinal); Assert.Contains("SystemTweaks_ToggleOff", serialized, StringComparison.Ordinal); - // The label and the thumb must be docked rather than stacked: a long localized label - // (ru "ВЫКЛ") has to widen the pill, not slide underneath the thumb. var track = Assert.Single( document.Descendants(), element => element.Name.LocalName == "Border" && element.Attributes().Any(attribute => attribute.Name.LocalName == "Name" && attribute.Value == "Track")); - Assert.Single(track.Descendants(), element => element.Name.LocalName == "DockPanel"); + Assert.Equal("40", track.Attribute("Width")?.Value); + Assert.Equal("20", track.Attribute("Height")?.Value); - var pill = Assert.Single( - document.Descendants(), - element => element.Name.LocalName == "Style" && - element.Attributes().Any(attribute => - attribute.Name.LocalName == "Key" && attribute.Value == "PillToggleButtonStyle")); - var setters = pill - .Elements() - .Where(element => element.Name.LocalName == "Setter") - .ToDictionary( - element => element.Attribute("Property")!.Value, - element => element.Attribute("Value")?.Value, - StringComparer.Ordinal); - - // MinWidth, never Width: a fixed track clips the wider locales. - Assert.Equal("56", setters["MinWidth"]); - Assert.Equal("28", setters["Height"]); - Assert.False(setters.ContainsKey("Width")); + // The label lives outside the track: a long translation ("Disattivato") widens the control + // instead of sliding under the thumb. + Assert.DoesNotContain(track.Descendants(), element => element.Attributes().Any(attribute => + attribute.Name.LocalName == "Name" && attribute.Value == "StateLabel")); } [Fact] diff --git a/Tests/ThreadPilot.Core.Tests/TestUiStateIsolation.cs b/Tests/ThreadPilot.Core.Tests/TestUiStateIsolation.cs new file mode 100644 index 0000000..d51c4f5 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/TestUiStateIsolation.cs @@ -0,0 +1,14 @@ +namespace ThreadPilot.Core.Tests +{ + using System.IO; + using System.Runtime.CompilerServices; + using ThreadPilot.Services; + + internal static class TestUiStateIsolation + { + // View models read and write UI layout state; keep tests away from the real user's file. + [ModuleInitializer] + internal static void Initialize() => + UiStateStore.FilePath = Path.Combine(Path.GetTempPath(), $"threadpilot-tests-{Environment.ProcessId}", "ui-state.json"); + } +} 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); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/UiStateStoreTests.cs b/Tests/ThreadPilot.Core.Tests/UiStateStoreTests.cs new file mode 100644 index 0000000..826958a --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/UiStateStoreTests.cs @@ -0,0 +1,35 @@ +namespace ThreadPilot.Core.Tests +{ + using System.IO; + using ThreadPilot.Services; + + [Collection("UiStateStore")] + public sealed class UiStateStoreTests + { + [Fact] + public void Update_SurvivesARestart() + { + UiStateStore.Update(s => s with { NavigationPaneOpen = false, HideSystemProcesses = true, WindowWidth = 1234 }); + + UiStateStore.ResetForTests(); // next read comes from disk, as after a restart + var state = UiStateStore.Current; + + Assert.False(state.NavigationPaneOpen); + Assert.True(state.HideSystemProcesses); + Assert.Equal(1234, state.WindowWidth); + } + + [Fact] + public void CorruptFile_FallsBackToDefaults() + { + Directory.CreateDirectory(Path.GetDirectoryName(UiStateStore.FilePath)!); + File.WriteAllText(UiStateStore.FilePath, "{ not json"); + + UiStateStore.ResetForTests(); + + Assert.True(UiStateStore.Current.NavigationPaneOpen); + File.Delete(UiStateStore.FilePath); + UiStateStore.ResetForTests(); + } + } +} diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index 514220f..1131df3 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -16,10 +16,10 @@ link true CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 - 1.7.6 - 1.7.6.0 - 1.7.6.0 - 1.7.6 + 1.7.7-beta.2 + 1.7.7.0 + 1.7.7.0 + 1.7.7-beta.2 @@ -43,10 +43,10 @@ - - - - + + + + diff --git a/ViewModels/LogViewerViewModel.cs b/ViewModels/LogViewerViewModel.cs index 21cea6a..4176751 100644 --- a/ViewModels/LogViewerViewModel.cs +++ b/ViewModels/LogViewerViewModel.cs @@ -78,6 +78,7 @@ public partial class LogViewerViewModel : ObservableObject, IDisposable "Settings", "Tweaks", "Optimization", + "Game Boost", "Diagnostics", "Safety", }; diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs index 1de8e09..6a13b0d 100644 --- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs +++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs @@ -127,6 +127,19 @@ public ProcessPowerPlanAssociationViewModel( this.persistentRuleStore = persistentRuleStore; this.ruleCreationService = ruleCreationService; + if (localizationService != null) + { + localizationService.LanguageChanged += (_, _) => + { + this.OnPropertyChanged(nameof(this.SavedRulePriorityOptions)); + this.OnPropertyChanged(nameof(this.SavedRuleMemoryPriorityOptions)); + this.OnPropertyChanged(nameof(this.SavedRuleIoPriorityOptions)); + this.OnPropertyChanged(nameof(this.SavedRuleEfficiencyModeOptions)); + this.OnPropertyChanged(nameof(this.SavedRuleCpuAssignmentModeOptions)); + TaskSafety.FireAndForget(this.RefreshSavedProcessRulesAsync(), _ => { }); + }; + } + // Subscribe to events this.associationService.ConfigurationChanged += this.OnConfigurationChanged; this.monitorManagerService.ServiceStatusChanged += this.OnServiceStatusChanged; @@ -158,7 +171,7 @@ public async Task RefreshSavedProcessRulesAsync() this.SavedProcessRules.Clear(); foreach (var rule in rules.OrderBy(rule => rule.ProcessName, StringComparer.OrdinalIgnoreCase)) { - this.SavedProcessRules.Add(SavedProcessRule.From(rule)); + this.SavedProcessRules.Add(SavedProcessRule.From(rule, this.Localize)); } this.HasSavedProcessRules = this.SavedProcessRules.Count > 0; @@ -227,27 +240,40 @@ private async Task DeleteSavedProcessRuleAsync(SavedProcessRule? rule) // Realtime is deliberately absent: the service refuses it, so offering it would only // produce an error the user cannot resolve. - public IReadOnlyList SavedRulePriorityOptions { get; } = - [ - NoneOption, - nameof(System.Diagnostics.ProcessPriorityClass.Idle), - nameof(System.Diagnostics.ProcessPriorityClass.BelowNormal), - nameof(System.Diagnostics.ProcessPriorityClass.Normal), - nameof(System.Diagnostics.ProcessPriorityClass.AboveNormal), - nameof(System.Diagnostics.ProcessPriorityClass.High), - ]; - - public IReadOnlyList SavedRuleMemoryPriorityOptions { get; } = - [NoneOption, .. Enum.GetNames()]; - - public IReadOnlyList SavedRuleIoPriorityOptions { get; } = - [NoneOption, .. Enum.GetNames()]; - - public IReadOnlyList SavedRuleEfficiencyModeOptions { get; } = - [NoneOption, .. Enum.GetNames()]; - - public IReadOnlyList SavedRuleCpuAssignmentModeOptions { get; } = - [.. Enum.GetNames()]; + // Values stay the enum names the editor already stores; only the labels are localized. + public IReadOnlyList SavedRulePriorityOptions => this.Options( + "ProcessView_Priority", + [ + NoneOption, + nameof(System.Diagnostics.ProcessPriorityClass.Idle), + nameof(System.Diagnostics.ProcessPriorityClass.BelowNormal), + nameof(System.Diagnostics.ProcessPriorityClass.Normal), + nameof(System.Diagnostics.ProcessPriorityClass.AboveNormal), + nameof(System.Diagnostics.ProcessPriorityClass.High), + ]); + + public IReadOnlyList SavedRuleMemoryPriorityOptions => + this.Options("ProcessView_Priority", [NoneOption, .. Enum.GetNames()]); + + public IReadOnlyList SavedRuleIoPriorityOptions => + this.Options("ProcessView_Priority", [NoneOption, .. Enum.GetNames()]); + + public IReadOnlyList SavedRuleEfficiencyModeOptions => + this.Options("EfficiencyMode_", [NoneOption, .. Enum.GetNames()]); + + public IReadOnlyList SavedRuleCpuAssignmentModeOptions => + this.Options("CpuAssignmentMode_", [.. Enum.GetNames()]); + + private RuleOption[] Options(string labelPrefix, IReadOnlyList values) => + values.Select(value => new RuleOption( + value, + value == NoneOption ? this.Localize("Rules_NotSet", "Not set") + // Windows calls the Idle class "Low", as Task Manager does. + : value == nameof(System.Diagnostics.ProcessPriorityClass.Idle) ? this.Localize("ProcessView_PriorityLow", "Low") + : this.Localize(labelPrefix + value, value))) + .ToArray(); + + public sealed record RuleOption(string Value, string Label); partial void OnSelectedSavedProcessRuleChanged(SavedProcessRule? value) { @@ -331,44 +357,49 @@ public sealed record SavedProcessRule( string Cores, PersistentProcessRule Source) { - public static SavedProcessRule From(PersistentProcessRule rule) + // text(key, fallback) resolves a localized string; the fallbacks keep the list readable without one. + public static SavedProcessRule From(PersistentProcessRule rule, Func text) { + string Level(object value) => value.ToString() == nameof(System.Diagnostics.ProcessPriorityClass.Idle) + ? text("ProcessView_PriorityLow", "Low") + : text("ProcessView_Priority" + value, value.ToString()!); + var applies = new List(); if (rule.ApplyAffinityOnStart) { - applies.Add("CPU assignment"); + applies.Add(text("ProcessView_CoresSection", "CPU cores")); } if (rule.ApplyPriorityOnStart && rule.Priority.HasValue) { - applies.Add($"priority {rule.Priority}"); + applies.Add($"{text("ProcessView_RowCpu", "CPU")} {Level(rule.Priority.Value)}"); } if (rule.ApplyMemoryPriorityOnStart && rule.MemoryPriority.HasValue) { - applies.Add($"memory {rule.MemoryPriority}"); + applies.Add($"{text("ProcessView_RowMemory", "Memory")} {Level(rule.MemoryPriority.Value)}"); } if (rule.ApplyIoPriorityOnStart && rule.IoPriority.HasValue) { - applies.Add($"I/O {rule.IoPriority}"); + applies.Add($"{text("ProcessView_RowIo", "I/O")} {Level(rule.IoPriority.Value)}"); } if (rule.ApplyEfficiencyModeOnStart && rule.EfficiencyMode.HasValue) { - applies.Add($"efficiency {rule.EfficiencyMode}"); + applies.Add($"{text("ProcessView_RowEfficiency", "Efficiency")} {text("EfficiencyMode_" + rule.EfficiencyMode.Value, rule.EfficiencyMode.Value.ToString())}"); } if (rule.PreventSystemSleepWhileRunning) { - applies.Add("keeps the system awake"); + applies.Add(text("Rules_AppliesKeepAwake", "keeps the PC awake")); } return new SavedProcessRule( rule.Id, - string.IsNullOrWhiteSpace(rule.ProcessName) ? "(unnamed)" : rule.ProcessName, - applies.Count == 0 ? "nothing" : string.Join(", ", applies), - rule.ApplyAffinityOnStart ? rule.CpuAssignmentMode.ToString() : "-", + string.IsNullOrWhiteSpace(rule.ProcessName) ? text("Rules_Unnamed", "(unnamed)") : rule.ProcessName, + applies.Count == 0 ? text("Rules_AppliesNothing", "nothing") : string.Join(", ", applies), + rule.ApplyAffinityOnStart ? text("CpuAssignmentMode_" + rule.CpuAssignmentMode, rule.CpuAssignmentMode.ToString()) : "-", rule.IsEnabled, rule.UpdatedAt.ToLocalTime(), DescribeCores(rule), @@ -876,8 +907,9 @@ private void OnProcessPowerPlanChanged(object? sender, ProcessPowerPlanChangeEve }); } + // GetString echoes the key when it is missing, so treat that as "no translation". private string Localize(string key, string fallback) => - this.localizationService?.GetString(key) ?? fallback; + this.localizationService?.GetString(key) is { Length: > 0 } text && text != key ? text : fallback; private void UpdateServiceStatus() { diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 9171fec..cb0d294 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -2143,6 +2143,7 @@ partial void OnSearchTextChanged(string value) partial void OnShowActiveApplicationsOnlyChanged(bool value) { + UiStateStore.Update(s => s with { ShowActiveApplicationsOnly = value }); _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => { await LoadProcessesCommand.ExecuteAsync(null); @@ -2151,16 +2152,19 @@ partial void OnShowActiveApplicationsOnlyChanged(bool value) partial void OnHideSystemProcessesChanged(bool value) { + UiStateStore.Update(s => s with { HideSystemProcesses = value }); this.filterRefreshCoordinator.Schedule(); } partial void OnHideIdleProcessesChanged(bool value) { + UiStateStore.Update(s => s with { HideIdleProcesses = value }); this.filterRefreshCoordinator.Schedule(); } partial void OnSortModeChanged(string value) { + UiStateStore.Update(s => s with { SortMode = value }); this.filterRefreshCoordinator.Schedule(); } @@ -2219,6 +2223,13 @@ private void FilterProcesses() var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); this.FilteredProcesses = new ObservableCollection(filteredResults); + + // The batch total is a snapshot from the first load; refreshes add processes, so the + // counter showed "157 / 151". Once everything is loaded the list itself is the total. + if (!this.HasMoreBatches || this.Processes.Count > this.TotalProcessCount) + { + this.TotalProcessCount = this.Processes.Count; + } } while (this.filterRefreshPending); } diff --git a/ViewModels/ProcessViewModel.PriorityEditor.cs b/ViewModels/ProcessViewModel.PriorityEditor.cs index e85e760..7a4caaf 100644 --- a/ViewModels/ProcessViewModel.PriorityEditor.cs +++ b/ViewModels/ProcessViewModel.PriorityEditor.cs @@ -1,5 +1,6 @@ namespace ThreadPilot.ViewModels { + using System.ComponentModel; using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -8,31 +9,58 @@ namespace ThreadPilot.ViewModels public partial class ProcessViewModel { + // Null shows an empty picker: the value could not be read for this process. [ObservableProperty] - private ProcessPriorityClass pendingCpuPriority = ProcessPriorityClass.Normal; + private ProcessPriorityClass? pendingCpuPriority; [ObservableProperty] - private ProcessMemoryPriority pendingMemoryPriority = ProcessMemoryPriority.Normal; + private ProcessMemoryPriority? pendingMemoryPriority; [ObservableProperty] - private ProcessIoPriority pendingIoPriority = ProcessIoPriority.Normal; + private ProcessIoPriority? pendingIoPriority; [ObservableProperty] - private ProcessEfficiencyMode pendingEfficiencyMode = ProcessEfficiencyMode.SystemManaged; + private ProcessEfficiencyMode? pendingEfficiencyMode; private PriorityUndoSnapshot? priorityUndo; [RelayCommand] - private Task ApplySelectedCpuPriority() => this.SetContextCpuPriorityAsync(this.SelectedProcess, this.PendingCpuPriority); + private Task ApplySelectedCpuPriority() => + this.PendingCpuPriority is { } value ? this.SetContextCpuPriorityAsync(this.SelectedProcess, value) : Task.CompletedTask; [RelayCommand] - private Task ApplySelectedMemoryPriority() => this.SetContextMemoryPriorityAsync(this.SelectedProcess, this.PendingMemoryPriority); + private Task ApplySelectedMemoryPriority() => + this.PendingMemoryPriority is { } value ? this.SetContextMemoryPriorityAsync(this.SelectedProcess, value) : Task.CompletedTask; [RelayCommand] - private Task ApplySelectedIoPriority() => this.SetContextIoPriorityAsync(this.SelectedProcess, this.PendingIoPriority); + private Task ApplySelectedIoPriority() => + this.PendingIoPriority is { } value ? this.SetContextIoPriorityAsync(this.SelectedProcess, value) : Task.CompletedTask; [RelayCommand] - private Task ApplySelectedEfficiencyMode() => this.SetContextEfficiencyModeAsync(this.SelectedProcess, this.PendingEfficiencyMode); + private Task ApplySelectedEfficiencyMode() => + this.PendingEfficiencyMode is { } value ? this.SetContextEfficiencyModeAsync(this.SelectedProcess, value) : Task.CompletedTask; + + // The summary is re-read on selection and after every change, so the pickers start from what the process has now. + private void OnSelectedProcessSummaryChanged(object? sender, PropertyChangedEventArgs e) + { + var summary = this.SelectedProcessSummary; + switch (e.PropertyName) + { + case nameof(SelectedProcessSummaryViewModel.CpuPriority): + case nameof(SelectedProcessSummaryViewModel.HasSelection): + this.PendingCpuPriority = summary.HasSelection ? summary.CpuPriority : null; + break; + case nameof(SelectedProcessSummaryViewModel.MemoryPriority): + this.PendingMemoryPriority = summary.MemoryPriority; + break; + case nameof(SelectedProcessSummaryViewModel.IoPriority): + this.PendingIoPriority = summary.IoPriority; + break; + case nameof(SelectedProcessSummaryViewModel.EfficiencyMode): + this.PendingEfficiencyMode = summary.EfficiencyMode; + break; + } + } [RelayCommand] private Task SetContextEfficiencyOn(ProcessModel? process) => this.SetContextEfficiencyModeAsync(process, ProcessEfficiencyMode.On); diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 4284250..9ae4c29 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -229,6 +229,13 @@ public ProcessViewModel( this.persistentRuleStore = persistentRuleStore; this.persistentRuleMatcher = persistentRuleMatcher; this.SelectedCpuAssignmentMode = settingsService?.Settings.DefaultCpuAssignmentMode ?? CpuAssignmentMode.Automatic; + + // Fields, not properties: the change handlers would reload and re-save the list during construction. + var uiState = UiStateStore.Current; + this.showActiveApplicationsOnly = uiState.ShowActiveApplicationsOnly; + this.hideSystemProcesses = uiState.HideSystemProcesses; + this.hideIdleProcesses = uiState.HideIdleProcesses; + this.sortMode = string.IsNullOrWhiteSpace(uiState.SortMode) ? this.sortMode : uiState.SortMode; this.IsAutomationMonitoringEnabled = settingsService?.Settings.EnableAutomationMonitoring ?? true; if (settingsService != null) { @@ -250,6 +257,7 @@ public ProcessViewModel( persistentRuleMatcher, localizationService, SystemTweaksService.IsPowerThrottlingOff); + this.SelectedProcessSummary.PropertyChanged += this.OnSelectedProcessSummaryChanged; // Subscribe to topology detection events this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; diff --git a/ViewModels/SelectedProcessSummaryViewModel.cs b/ViewModels/SelectedProcessSummaryViewModel.cs index 3244b00..958a2e8 100644 --- a/ViewModels/SelectedProcessSummaryViewModel.cs +++ b/ViewModels/SelectedProcessSummaryViewModel.cs @@ -28,6 +28,7 @@ public sealed class SelectedProcessSummaryViewModel : ObservableObject private ProcessIoPriority? ioPriority; private ProcessEfficiencyMode? efficiencyMode; private string efficiencyModeText = "Efficiency mode unavailable"; + private bool isEfficiencyModeVoided; private string processTitle = "No process selected"; private string currentProcessStatusText = "No process selected"; private string cpuUsageText = "CPU: unavailable"; @@ -130,6 +131,12 @@ public string EfficiencyModeText private set => this.SetProperty(ref this.efficiencyModeText, value); } + public bool IsEfficiencyModeVoided + { + get => this.isEfficiencyModeVoided; + private set => this.SetProperty(ref this.isEfficiencyModeVoided, value); + } + public string ProcessTitle { get => this.processTitle; @@ -289,6 +296,7 @@ private void Clear(int version, string? lastOperationMessage, bool lastOperation this.MemoryPriority = null; this.IoPriority = null; this.EfficiencyMode = null; + this.IsEfficiencyModeVoided = false; this.IsProtectedOrAccessDenied = false; this.HasThreadPilotRule = false; this.ProcessTitle = this.L("ProcessView_NoProcessSelected", "No process selected"); @@ -371,6 +379,7 @@ private async Task UpdateIoPriorityAsync(ProcessModel process, int version) private async Task UpdateEfficiencyModeAsync(ProcessModel process, int version) { this.EfficiencyMode = null; + this.IsEfficiencyModeVoided = false; this.EfficiencyModeText = this.L("ProcessSummary_EfficiencyUnavailable", "Efficiency mode unavailable"); if (this.memoryPriorityService == null) { @@ -387,7 +396,8 @@ private async Task UpdateEfficiencyModeAsync(ProcessModel process, int version) this.EfficiencyMode = mode.Value; var label = this.L($"EfficiencyMode_{mode.Value}", mode.Value.ToString()); - this.EfficiencyModeText = mode == ProcessEfficiencyMode.On && this.isPowerThrottlingOff() + this.IsEfficiencyModeVoided = mode == ProcessEfficiencyMode.On && this.isPowerThrottlingOff(); + this.EfficiencyModeText = this.IsEfficiencyModeVoided ? this.L("ProcessSummary_EfficiencyVoidedFormat", "Efficiency mode: {0} (no effect: power throttling is disabled in Tweaks)", label) : this.L("ProcessSummary_EfficiencyFormat", "Efficiency mode: {0}", label); } diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index cdd1c7d..7c765e5 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -908,6 +908,5 @@ private void SetSavedSettingsSnapshot(ApplicationSettingsModel settingsSnapshot) this.savedSettingsSnapshot = (ApplicationSettingsModel)settingsSnapshot.Clone(); this.HasUnsavedChanges = false; } - } } 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/ProcessPowerPlanAssociationView.xaml b/Views/ProcessPowerPlanAssociationView.xaml index 097e5fd..f9305fe 100644 --- a/Views/ProcessPowerPlanAssociationView.xaml +++ b/Views/ProcessPowerPlanAssociationView.xaml @@ -61,6 +61,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ToolTip="{DynamicResource ProcessView_CpuAssignmentModeTooltip}" + AutomationProperties.Name="{DynamicResource ProcessView_CpuAssignmentMode}" + AutomationProperties.HelpText="{DynamicResource ProcessView_CpuAssignmentModeTooltip}"> + + + + - - - - - - + + + + + + + + + + + - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + + - - + - + - + - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - + + + + diff --git a/app.manifest b/app.manifest index 3135866..16e19a7 100644 --- a/app.manifest +++ b/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index 9a4d78e..81a7308 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.6", + [string]$Version = "1.7.7-beta.2", [string]$Configuration = "Release", [switch]$SkipPublish ) diff --git a/build/build-release.ps1 b/build/build-release.ps1 index b2392ec..d2536c8 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.6", + [string]$Version = "1.7.7-beta.2", [string]$Configuration = "Release", [string]$Runtime = "win-x64" ) diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1 index 46b9e1a..6376ce6 100644 --- a/build/package-release-zips.ps1 +++ b/build/package-release-zips.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.6" + [string]$Version = "1.7.7-beta.2" ) $ErrorActionPreference = "Stop" diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec index 197a72e..fef9a74 100644 --- a/chocolatey/threadpilot.nuspec +++ b/chocolatey/threadpilot.nuspec @@ -2,7 +2,7 @@ threadpilot - 1.7.6 + 1.7.7-beta.2 ThreadPilot Prime Build https://github.com/PrimeBuild-pc/ThreadPilot @@ -15,7 +15,7 @@ false Advanced Windows process and power plan manager with rules automation and performance controls. ThreadPilot process and power plan manager. - https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.7.6 + https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.7.7-beta.2 threadpilot process powerplan performance windows diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0c7d4f1..fb78ee8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project are documented in this file. +## v1.7.7-beta.2 - Game Boost hardware-test pre-release + +- Opt-in Game Boost: topology-aware game/background CPU Sets, optional priority, safe restoration, crash recovery and Activity diagnostics. +- Correct Intel P/E classification and derive AMD CCD groups from real L3 topology, including 6+6 CPUs. +- Three-state EcoQoS for live processes and persistent rules, independent of CPU priority. +- Persistent-rule backups, validated/transactional imports, deterministic precedence and conflict reporting. +- Refreshed navigation, process/rule editors, Windows 11 settings rows, layout persistence and scaling fixes. +- CI and release native tests run on Windows Server 2025: Server 2022 build 20348 rejects EcoQoS read-back with Win32 error 87, even with full query access. The real integration test remains enabled. +- Pre-release publication keeps the stable/latest release and package-manager channels unchanged. +- Real Game Boost validation on dual-CCD Ryzen/X3D and Intel hybrid CPUs, gaming benchmarks and anti-cheat testing remain pending. See [test procedure and reporting checklist](releases/v1.7.7-beta.2.md). + ## v1.7.6 - Localized power plans and version-proof process rules ### Fixed diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..4c6e088 --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,32 @@ +# Handoff ThreadPilot — 2026-10-16 + +## Versione e ambito + +La versione preparata è **v1.7.7-beta.2**, esclusivamente pre-release. Include la pila #67–#70, le correzioni della topologia CPU e Game Boost (#71), comprese le modifiche locali del 15 ottobre alla diagnostica Attività. La conferma hardware EcoQoS di SA-Mousavichashmi è nella #66; non equivale alla validazione di Game Boost. + +Fonte pubblica per funzionalità, limiti, test richiesti e dati da allegare alle issue: `docs/releases/v1.7.7-beta.2.md`. Queste note vengono usate dal workflow Release. v1.7.6 deve restare stable/latest; la beta non va pubblicata su winget o Chocolatey. I binari sono ancora unsigned. + +## Validazione hardware ancora necessaria + +- Game Boost su Ryzen dual-CCD X3D (7900X3D/7950X3D/9900X3D/9950X3D), incluso il parcheggio dei core del driver AMD. +- AMD a CCD uguali (7950X/9950X): confronto Automatico/Isolamento. +- Intel ibrida: P-core al gioco, E-core al background sulle topologie supportate; ripiego conservativo se il lato gioco ha meno di sei core fisici. +- Benchmark ripetibili con/senza background e Above Normal, almeno tre passaggi per scenario; FPS medi, 1% low, P95/P99 dei frame time. +- Giochi protetti/anti-cheat reali, stuttering, crash e regressioni. + +QA precedente: Ryzen 7 5800X3D single-CCD e VM Windows 11 pulita; impostazioni, sessioni, priorità, rispetto delle modifiche manuali/regole e recupero dopo crash. Il test nativo CPU Sets usa vere chiamate Windows ma topologia dual-L3 simulata: non dimostra il comportamento su vero hardware multi-CCD. I test nativi Game Boost non esercitano lo split sui piccoli runner CI. + +## Verifiche e pubblicazione + +- Test Release locali dopo la preparazione beta: 851 passati; audit dipendenze senza vulnerabilità segnalate; installer/portable generati e smoke test del single-file riuscito. +- Causa della CI EcoQoS: Server 2022 build 20348 rifiuta GetProcessInformation(ProcessPowerThrottling) con errore 87 sia con query limitata sia completa. I job con test nativi e smoke usano ora Server 2025, coerente con il target Windows 11; il round-trip reale non viene saltato. +- Per stato effettivo di merge/release/CI consultare GitHub, non dedurlo dalla presenza di questo file: `gh pr list`, `gh run list`, `gh release view v1.7.7-beta.2`. +- Prima del tag verificare CI della combinazione finale e corrispondenza del codice con tutte le modifiche locali. Il tag deve puntare al commit integrato in main. +- Dopo il tag verificare artifact installer/portable/SHA256, smoke test, flag prerelease=true e `/releases/latest` ancora su v1.7.6. +- Pulire solo i branch della pila e Game Boost dopo l'integrazione; lasciare intatti worktree e branch di altri lavori. + +## Convenzioni + +- Fine riga misti: prima del commit confrontare `git diff --numstat` e `git diff --ignore-cr-at-eol --numstat`. +- Sette lingue in `Locales/*.xaml`, con le stesse chiavi; layout coperto dai test di contratto XAML. +- Per altri dettagli di sviluppo: `docs/IMPLEMENTATION_PLAN.md`, `docs/UX_REDESIGN_PLAN.md`, `docs/release/RELEASE_RUNBOOK.md`. diff --git a/docs/UX_REDESIGN_PLAN.md b/docs/UX_REDESIGN_PLAN.md new file mode 100644 index 0000000..5c75e93 --- /dev/null +++ b/docs/UX_REDESIGN_PLAN.md @@ -0,0 +1,33 @@ +# Redesign UX — chiarezza (ottobre 2026) + +Branch `feat/ux-clarity`, impilato su #69 (EcoQoS). Nessuna modifica al formato dei dati: regole, impostazioni e backup restano compatibili. + +## Problemi osservati (screenshot dell'app 1.7.6) + +- Menu laterale solo a icone: sette icone senza etichetta. +- Processi: casella di ricerca senza testo guida, nove controlli sparsi nella barra, pannello dettagli come un'unica colonna di etichette e paragrafi, selettori che non mostrano il valore attuale del processo. +- Regole: tre schede e la prima aperta è la meno usata (associazioni dei piani energetici); nell'editor le scelte compaiono come nomi interni (`BelowNormal`, `SystemManaged`). +- Sottotitoli lunghi su quasi tutte le pagine. + +## Regole di stile + +1. Titolo della pagina e al massimo una riga breve; le spiegazioni vanno nei tooltip. +2. Una sezione = un riquadro con un titolo breve. +3. Un solo pulsante principale (accent) per riquadro. +4. I selettori mostrano lo stato attuale; "Applica" agisce solo sul valore cambiato. +5. Etichette leggibili per le persone, mai nomi interni di enum. + +## Interventi + +- [x] Menu laterale aperto con etichette brevi (Processi, Maschere CPU, Piani energetici, Regole, Attività, Ottimizzazioni). +- [x] Processi: ricerca con testo guida; filtri e colonne in due menu a tendina; dettagli in tre riquadri (Processo, Priorità, Core CPU) più la riga della regola; selettori allineati allo stato attuale; testi di aiuto nei tooltip. +- [x] Regole: scheda "Regole dei processi" per prima, nomi delle schede più chiari, etichette localizzate nell'editor. +- [x] Sottotitoli accorciati in tutte le lingue. +- [x] Impostazioni e Ottimizzazioni in una colonna di righe in stile Impostazioni di Windows 11 (titolo e descrizione a sinistra, interruttore a destra); sezioni più usate in alto. +- [x] Avvisi della finestra principale: già mostrati uno alla volta (coda elevazione → avvio ridotto → modalità CPU in `MainWindow.Behaviors.partial.cs`); nessuna modifica necessaria. +- [x] Elenco regole: colonne "Applica" e "Modalità" localizzate. +- [x] Rimosse 76 stringhe non più usate da tutte le lingue. + +## Verifica + +Test XAML esistenti aggiornati dove cambiano i contratti, suite completa, build Debug/Release, screenshot prima/dopo di ogni pagina. diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md index 2c57dcd..136b240 100644 --- a/docs/release/RELEASE_NOTES.md +++ b/docs/release/RELEASE_NOTES.md @@ -1,3 +1,11 @@ +## ThreadPilot v1.7.7-beta.2 + +Pre-release only: Game Boost, EcoQoS, topology fixes, persistent-rule/backup reliability and the complete UI refresh, including all local Game Boost work through 2026-10-16. + +**Game Boost still needs real dual-CCD Ryzen/X3D and Intel hybrid hardware validation, comparative gaming benchmarks and protected-game/anti-cheat testing.** No performance improvement is claimed. + +Full release description, quick test steps and issue-reporting checklist: [v1.7.7-beta.2](../releases/v1.7.7-beta.2.md). Artifacts are unsigned. v1.7.6 remains stable/latest; no beta publication to winget or Chocolatey. + ## ThreadPilot v1.7.6 This release fixes power plan discovery on localized Windows installations and makes executable-path rules survive application updates. diff --git a/docs/release/RELEASE_RUNBOOK.md b/docs/release/RELEASE_RUNBOOK.md index b22f708..f1e56e9 100644 --- a/docs/release/RELEASE_RUNBOOK.md +++ b/docs/release/RELEASE_RUNBOOK.md @@ -42,7 +42,8 @@ Source-of-truth policy: - GitHub release tag and assets are the canonical release source. - Winget and Chocolatey metadata must reference an existing GitHub release tag and reachable asset URLs. - Changelog generation in CI must run with runner-native `git-cliff` binary (pinned version + checksum verification), not Docker-based changelog actions. -- Tagged releases are public-release mode: missing winget publication secrets are a release blocker, not a silent skip. +- Stable tagged releases are public-release mode: missing winget publication secrets are a release blocker, not a silent skip. +- Pre-release tags (for example `v1.7.7-beta.2`) use matching project metadata and `docs/releases/.md` tester notes. The workflow marks them as pre-release, leaves latest unchanged, and skips winget manifests/publication. Chocolatey remains manual and must not be published for this beta. - `workflow_dispatch` may be run with `dry_run_publish=true` to validate build, assets, summaries, and winget flow without external publication. - Chocolatey publish is manual only through the `Publish Chocolatey` workflow, after prior ThreadPilot versions clear Chocolatey moderation. - Every publish channel that runs must leave evidence behind via `GITHUB_STEP_SUMMARY` plus uploaded log artifacts. diff --git a/docs/releases/v1.7.7-beta.2.md b/docs/releases/v1.7.7-beta.2.md new file mode 100644 index 0000000..119c4ce --- /dev/null +++ b/docs/releases/v1.7.7-beta.2.md @@ -0,0 +1,55 @@ +# ThreadPilot v1.7.7-beta.2 — Game Boost & efficiency mode + +**Pre-release for testing, not a stable release.** This build includes all changes since v1.7.6, including the latest local Game Boost work. v1.7.6 remains the stable/latest release; this beta is not distributed through winget, Chocolatey or stable automatic updates. + +## What's included + +- **Game Boost (opt-in):** topology-aware CPU Sets for the game and explicitly listed background apps, optional Above Normal priority, persistent-rule precedence, restoration when the game exits or Game Boost is disabled, and crash-recovery journaling. Activity now shows session start/end, what was applied or left unchanged, and restore results. +- **CPU topology fixes:** Intel P/E cores use the highest Windows EfficiencyClass for P-cores; AMD CCD groups come from real L3 groups and sizes, including 6+6 layouts. +- **Efficiency mode / EcoQoS:** Windows decides / On / Off, independently of CPU priority, for live processes and saved rules. A warning explains the global Power Throttling override. +- **Rules and backup reliability:** persistent rules are included in configuration backups; imports are validated and rolled back on failure; overlapping rules have deterministic precedence and conflict reporting. +- **UI refresh:** labelled navigation, clearer Process and Rules editors, priority undo, Windows 11-style Settings/Tweaks, remembered layout and filters, scaling fixes, and seven-language localization. +- **CI/release fixes:** native EcoQoS tests now run on a Windows 11-era kernel (Server 2025), rather than Server 2022 which rejects power-throttling read-back. Beta tags remain pre-release and cannot replace the stable/latest release or enter the winget publication job. + +## Validation status — hardware testers needed + +**Game Boost is not yet validated on real dual-CCD Ryzen / dual-CCD X3D or Intel hybrid hardware, and comparative gaming benchmarks are still pending. No FPS improvement is claimed.** + +The previous QA covered a single-CCD Ryzen 7 5800X3D and a clean Windows 11 VM: UI/settings, session detection, optional priority, respecting manual changes and rules, and crash recovery. Real Windows CPU Set apply/read/restore calls were tested with a simulated dual-L3 topology; that is not a substitute for real multi-CCD hardware. Anti-cheat/protected-game behavior still needs real-game testing. + +EcoQoS itself (not Game Boost) was independently verified by @SA-Mousavichashmi on an Intel i5-12500H, Windows 11 build 26300.9711: On/Off/system-managed, persistent rules, independent CPU priority and protected-process failures. + +We especially need reports from: + +- **AMD dual-CCD X3D:** 7900X3D / 7950X3D / 9900X3D / 9950X3D. Verify that the game uses the V-Cache CCD and listed background apps use the other CCD; check for interaction with AMD chipset-driver core parking. +- **AMD equal-CCD CPUs:** e.g. 7950X / 9950X. Automatic should leave the game's placement to Windows and move only listed background apps; Isolation also places the game on CCD0. +- **Intel hybrid CPUs:** 12th–14th gen / Core Ultra, where supported. Verify game on P-cores and listed background apps on E-cores, especially in heavily multithreaded games. + +Game Boost conservatively leaves CPU Sets unchanged on unsupported/single-CCD CPUs, incomplete topology, or fewer than six physical cores on the game side. A laptop with fewer than six P-cores can therefore correctly report that Windows decides. Optional priority is separate. + +## Quick test procedure + +1. Export a configuration backup. Record your CPU, Windows build (`winver`), chipset-driver version, and for AMD whether the `amd3dvcache` service is installed. Keep power plan, game settings and background load consistent. +2. In **Tweaks / Ottimizzazioni → Game Boost**, leave **Above Normal off**, choose **Automatic**, add your game manually if it is outside a recognized library, and check the background-app list. Enable Game Boost, then bring the game to the foreground. +3. Read the topology decision on the Game Boost card and session events in **Activity**. Inspect actual process **CPU Sets** in ThreadPilot, System Informer or Process Lasso; Task Manager's classic affinity mask does not show soft CPU Sets. On dual-CCD X3D, expect 16 game logical CPUs for an 8-core V-Cache CCD or 12 for a 6-core CCD when SMT is enabled. On Intel, compare the game selection with the P-core thread count. +4. Alt+Tab: the session should remain active. Close the game or disable Game Boost: only changes owned by Game Boost should be restored. Existing rules and later manual changes should be respected. On equal-CCD AMD systems, repeat with **Isolation** and compare. Do not disable anti-cheat or bypass protected-process restrictions. +5. For performance, use CapFrameX or PresentMon with the same scene and **at least three runs per scenario**: (a) Game Boost off; (b) on, no background load; (c) on with browser video / Steam download; (d) same as (c), with Above Normal on. Compare average FPS, 1% lows, P95/P99 frame times and run-to-run variance. + +## Send us your results + +Open an [issue](https://github.com/PrimeBuild-pc/ThreadPilot/issues) titled **[1.7.7-beta.2][Game Boost] CPU model — result/problem**. Successful tests are useful too. Include: + +- Exact ThreadPilot version, CPU, SMT on/off, Windows build, chipset driver, active power plan, and AMD `amd3dvcache` service status. +- Game name/version, Game Boost mode, Above Normal setting, background apps/load, and any relevant saved rules or pre-existing CPU Sets. +- Expected versus actual result, reproduction steps, whether disabling Game Boost restores normal behavior, and any stutter, crash, anti-cheat refusal or regression. +- Screenshot of the Game Boost topology decision and actual game/background CPU Sets, plus relevant **Activity** entries/export. Attach CapFrameX/PresentMon captures or a small comparison table if available. + +Redact usernames, personal executable paths and unrelated log entries before uploading. + +## Known limits & safety + +- Above Normal only raises a game that starts at Normal; a game at another priority is left unchanged. +- Manual selection of exactly the CPU Sets that Game Boost already applied cannot be distinguished from its own selection and may be cleared at session end unless saved in a rule. +- Detection requires a recognized library plus foreground activity, or a manually listed game. Add Battle.net/other custom-install games explicitly when needed. +- Applications can override their own QoS or CPU Sets. Protected processes can refuse access; ThreadPilot does not bypass protection. +- Artifacts are **unsigned** while code-signing enrollment is pending; Windows SmartScreen may warn. Verify SHA256 before running. Upgrading preserves settings; uninstalling removes ThreadPilot-owned user data. diff --git a/sonar-project.properties b/sonar-project.properties index f1e134c..3256418 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ sonar.projectKey=threadpilot sonar.projectName=ThreadPilot -sonar.projectVersion=1.7.6 +sonar.projectVersion=1.7.7-beta.2 sonar.sourceEncoding=UTF-8 sonar.sources=.