├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── docs ├── HOW_IT_WORKS.md └── Images │ ├── v4.1.0_2025-05-31-09-43-54.gif │ └── v4.1.0_2025-05-31-09-50-18.gif └── src ├── BUILD.md ├── Public_SameSalamander5710.cer ├── __main__.py ├── core ├── DFL_v4.py ├── assets │ ├── DynamicFPSLimiter.ico │ ├── DynamicFPSLimiter_dark_green.ico │ ├── DynamicFPSLimiter_dark_red.ico │ ├── DynamicFPSLimiter_icon.png │ ├── DynamicFPSLimiter_red.ico │ ├── close_button.png │ ├── faqs.csv │ └── minimize_button.png ├── autopilot.py ├── autostart.py ├── config_manager.py ├── cpu_monitor.py ├── fps_utils.py ├── gpu_monitor.py ├── launch_popup.py ├── logger.py ├── rtss_functions.py ├── rtss_interface.py ├── themes.py ├── tooltips.py ├── tray_functions.py └── warning.py ├── metadata └── version.txt └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | venv/ 8 | 9 | # PyInstaller 10 | output/ 11 | build/ 12 | dist/ 13 | *.spec 14 | 15 | # IDE 16 | .vscode/ 17 | .idea/ 18 | 19 | # Environment 20 | .env 21 | src/config/ 22 | 23 | src/error_log.txt 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [v4.4.2-patch1] - 2025-08-11 4 | 5 | ### Fixed 6 | - Temporary workaround: Disabled the currently unused GetFlags functionality to prevent errors experienced by some users. 7 | - This feature is reserved for potential future use and does not affect current app functionality. 8 | 9 | ## [v4.4.2] - 2025-08-09 10 | 11 | ### Added 12 | - Moved the delay settings (before increase/decrease) from global preferences to profile-specific settings. These can now be changed directly in the app. 13 | 14 | ### Changed 15 | - Default maximum and minimum framerate limits updated: 16 | - Maximum FPS limit increased from 60 to 114. 17 | - Minimim FPS limit increased from 30 to 40. 18 | - Given the vastly improved experience when using LSFG with a base 40 FPS vs 30 FPS (~101.00 ms vs ~144.91 ms end-to-end latency respectively1, along with improved visuals), this serves as a subtle hint to avoid capping below 40 FPS when possible. The app still works the same and can be used with a 30 FPS cap if needed. 19 | - Default 'delay before increase' value raised from 3 seconds to 10 seconds.: 20 | - The microstutter that occurs when the game stalls during a framerate limit change can affect each game differently. A 10-second delay provides a less dynamic but smoother experience for new users and likely results in better performance in most games. The app still works the same and can be used with a 3 second delay if needed. 21 | 22 | ### References 23 | 1. [CptTombstone's post on r/losslessscaling](https://www.reddit.com/r/losslessscaling/comments/1mhfjnq/curious_about_the_latency_impact_of_lsfg_at/) 24 | 25 | ## [v4.4.1] - 2025-07-26 26 | 27 | ### Added 28 | - Cooldown period for FPS cap increase logic 29 | - Previously, when GPU usage stayed below the lower threshold for 3 continuous seconds (default `delaybeforeincrease`), the app began increasing the FPS cap once per second, leading to noticeable 1-second microstutters until GPU usage stabilized. 30 | - Now, after each FPS cap increase, the app enters a cooldown period (equal to `delaybeforeincrease`) before attempting another increase, only if the low GPU usage condition still holds. 31 | - Effect: This change spaces out FPS cap increases, making transitions from high-demand to low-demand scenes relatively smoother. 32 | - Example: If 3 FPS cap increases are needed: 33 | - Before: 3 microstutters over 3 seconds -> very jarring 34 | - Now: 3 microstutters over 9 seconds -> less intrusive 35 | 36 | ### Changed 37 | - Interal: Autopilot’s monitoring loop is now decoupled from the GUI update loop. 38 | - Increased internal wait times at various stages to reduce unnecessary polling and reduce CPU overhead. 39 | 40 | ### Fixed 41 | - Reduced CPU usage when idle and minimized to tray 42 | - The app previously consumed more CPU when idle due to continuous GUI updates. 43 | - GUI updates are now paused when minimized, resulting in significantly lower CPU usage while the app is idle in the background. 44 | 45 | ## [v4.4.0] - 2025-07-20 46 | 47 | ### Added 48 | - Autopilot mode 49 | - Automatically activates the corresponding profile when a saved application is detected and stops when focus shifts away from the game. 50 | - Includes alternate dark-mode tray icons to indicate Autopilot status when minimized. 51 | - Disables the Start/Stop buttons while active. To add a new profile: 52 | - Disable Autopilot 53 | - Manually start the Global profile (or any profile to copy it's existing settings) 54 | - Switch to and from the target game 55 | - Click 'Add process to profiles' 56 | - Re-enable Autopilot 57 | - A warning will appear if no non-Global profiles are available, as Autopilot requires at least one. 58 | - README and in-app FAQ updated with CPU usage mitigation steps: 59 | - In some specific scenarios (e.g., RTSS 'passive waiting' disabled for Global profile and a Twitch stream running in Chrome), the app may use 20–30% CPU even when idle. 60 | - Adding `DynamicFPSLimiter.exe` as a profile in RTSS with application detection set to None can reduce CPU usage to 1–3%. Use the **Shift** key + **Add** to directly add an active process for exclusion in RTSS. 61 | 62 | ### Changed 63 | - V-Sync enabled for the app's GUI 64 | - Minor GUI rearrangement to accommodate the Autopilot checkbox. 65 | - Updated tooltips and in-app FAQ entries. 66 | - Minor refactoring of functions/modules 67 | - Adjusted default values for cap_ratio and GPU usage thresholds. 68 | 69 | ### Fix 70 | - Profile name field is now read-only but selectable for copy/paste. 71 | - Useful for integration with tools like Lossless Scaling via its "filter" feature. 72 | 73 | ## [v4.3.0] - 2025-07-06 74 | 75 | ### Added 76 | - Minimize to Tray and Tray Features 77 | - Option to start the app minimized to the system tray. 78 | - Tray icon now supports click-to-toggle: 79 | - Green arrow: App is stopped — click to Start. 80 | - Red arrow: App is running — click to Stop. 81 | - Hovering over the tray icon shows the current status, including: 82 | - Selected profile 83 | - Active limit method 84 | - Maximum FPS defined by that method 85 | - Right-clicking the tray icon opens a menu to switch profiles and limit methods directly. 86 | - Improved FPS limit ramp-up for 'ratio' method 87 | - When transitioning from high GPU usage (demanding scenes) to low GPU usage (light scenes), the FPS limit now increases more quickly (-> in lesser number of steps). 88 | - Previously, the app would raise the FPS limit in single steps, causing multiple adjustments (and therefore mini-stutters) before reaching the optimal value. 89 | - With this update, the FPS limit can now increase in larger steps when appropriate, reducing the number of transitions needed to reach the maximum cap. This is dictated by how low the GPU usage is when the increase limit conditions are triggered. 90 | - The behavior remains conservative to avoid overshooting and triggering limit reductions right after. 91 | - RTSS installation prompt and download warning 92 | - If RTSSHooks64.dll is missing, the app now shows a pop-up guiding the user to install RTSS. 93 | - A warning message has also been added, recommending users download the app only from the official GitHub page, as third-party sites may host outdated or unsafe versions. 94 | 95 | ### Changed 96 | - Custom title bar implemented 97 | - Replaced the default OS title bar with a custom one. 98 | - The maximize button has been removed, as maximizing previously caused layout issues. 99 | - Code cleanup 100 | - Refactored various functions into separate modules for better organization and maintainability. 101 | 102 | ### Fixed 103 | - Start button color issue 104 | - Resolved a bug where the green color of the Start button was lost after clicking it twice. 105 | 106 | ### Known issues 107 | - Profile display name appears editable 108 | - Although the profile name field looks editable, changes are not saved. 109 | - This is currently only a temporary cosmetic change and has no functional effect. 110 | 111 | ## [v4.2.0] - 2025-06-15 112 | ### Added 113 | - Support for fractional framerate values in the custom FPS limit input. 114 | 115 | ### Changed 116 | - Uses simplified python script to directly use RTSSHooks64.dll. 117 | 118 | ### Fixed 119 | - Deleting a profile on DFL now deletes the corresponding profile on RTSS. 120 | 121 | ## [v4.1.0] - 2025-05-31 122 | ### Added 123 | - Support for custom FPS limit input. 124 | - New methods for calculating FPS limits using ratio and step approaches. 125 | - Users can configure both custom and calculated limits separately, allowing two different cap settings under the same profile and easy switching between them. 126 | - Overhauled GUI theme for an improved user experience. 127 | - Option to launch the app with Windows startup. 128 | - Ability to set the default profile that loads on app launch. 129 | - Warning when FPS limits are set below the minimum valid value (configurable in `settings.ini`). 130 | 131 | ### Changed 132 | - Updated internal logic to use lists (sets) for managing FPS limit values. 133 | - Refactored several parts of the original code for better maintainability. 134 | 135 | ## [v4.0.0] - 2025-05-09 136 | ### Added 137 | - Complete UI overhaul: Redesigned and simplified interface with proper DPI scaling and a more efficient window layout. 138 | - CPU usage monitoring: Dynamic framerate limit adjustments can now factor in CPU usage. 139 | - Process-based profile creation: Create profiles directly from currently running processes. 140 | - Added a beta FAQ tab 141 | 142 | ### Changed 143 | - Reworked GPU usage retrieval for more accurate and efficient monitoring (no longer uses PowerShell). 144 | - No RTSS configuration required; simply keep RTSS running in the background. 145 | - Improved profile handling and more intuitive controls for daily use. 146 | - Switched from using `rtss-cli.exe` to a direct Python wrapper for RTSS communication. 147 | - Refined framerate adjustment logic to better respond to real-time system load. 148 | - Improved repository structure and build instructions. 149 | - Removed build output from repository; they can only be found in the Releases. 150 | - `.ini` files are now located back to the main app directory. 151 | - Does not spawn any persistent subprocesses. 152 | 153 | ## [v3.0.2] - 2025-04-16 154 | ### Added 155 | - Compatibility for newer systems with PowerShell 7.x instead of Windows PowerShell 5.x. 156 | - When adding a new profile in DynamicFPSLimiter and hitting Start, the profile will be created in RTSS if it was not already present. 157 | 158 | ### Changed 159 | - Various minor changes to make the app more anti-virus friendly, including changes to how directories/paths are read. 160 | - `.ini` files are now located within the `_internal` folder. 161 | - Removed distributable files (output from pyinstaller) from the source to keep the repository clean. 162 | 163 | ### Fixed 164 | - Deleting a profile now updates internally without needing to select another profile. 165 | 166 | ### Known Issues 167 | - App default settings may not match the default "Global" profile settings, but this may be ignored. 168 | 169 | ## [v3.0.1] - 2025-04-15 170 | ### Added 171 | - Version information included in the executable. 172 | 173 | ### Changed 174 | - Removed 'Execution Policy Bypass' from the persistent hidden PowerShell process. 175 | - Converted PowerShell commands to single-line statements for improved compatibility. 176 | - Streamlined the build process for cleaner and easier release preparation directly from the source. 177 | 178 | ## [v3.0.0] - 2025-04-13 179 | ### Added 180 | - Integrated with @xanderfrangos's `rtss-cli.exe` to directly modify RTSS framerate limits - no more reliance on AutoHotkey or RTSS hotkeys. 181 | - All framerate control functionality is now fully handled within the DynamicFPSLimiter app - no manual updates in RTSS required. 182 | - "Detect Render GPU" button: Automatically detects the GPU used for game rendering by checking which GPU has the highest "3D engine" utilization at the time of clicking. Run this while the game is active to detect the render GPU. 183 | - Profiles functionality: Allows users to manually create target profiles that are already configured in RTSS. This is required to make changes to non-Global target profiles in RTSS. 184 | 185 | ### Note 186 | - Since RTSS runs as an elevated process, the app must be run as administrator to function properly. 187 | 188 | ## [v2.0.1] - 2025-04-11 189 | ### Changed 190 | - Switched from `Get-Counter` to `Get-CimInstance` for GPU usage retrieval, resulting in faster performance metrics. 191 | - Now includes all `engtype` values (e.g., 3D, Copy, Video, Compute) for a more comprehensive and accurate GPU utilization figure. 192 | - This change may also reduce the performance impact of the app during gameplay. 193 | - Renamed the button "Delay before increase/decrease" to "Instances before inc./dec." for better alignment with its actual function. 194 | - Updated related tooltips to reflect the new terminology and provide clearer guidance. 195 | 196 | ### Fixed 197 | - Improved support for regional number formatting differences (e.g., handling both `.` and `,` as decimal separators) to address None% GPU usage readout. 198 | - Enhanced robustness by stripping whitespace and enforcing UTF-8 encoding for PowerShell outputs. 199 | 200 | ## [v2.0.0] - 2025-04-07 201 | ### Added 202 | - Introduced a user-friendly GUI version of the original scripts, making the app accessible to general (non-technical) users. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 SameSalamander5710 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic FPS Limiter v4.4 2 | 3 | A lightweight companion app for RTSS that uses it's profile modification API to dynamically adjust framerate limits based on real-time GPU and CPU usage. 4 | - Instead of relying on a fixed FPS cap below average framerates for smooth gameplay, it intelligently raises the cap when performance headroom is available, allowing consistently smooth frametimes, with the caveat of a momentary stutter during FPS limit transition. 5 | - Especially useful for reducing input latency when using frame generation tools like Lossless Scaling, by ensuring there's always enough GPU headroom. 6 | - When paired with adaptive frame generation in Lossless Scaling, it enables a constant high refresh rate experience with lower power draw and reduced GPU temperatures, without noticeable visual compromises or input lag. 7 | 8 |

9 | 10 |   11 | 12 |

13 | 14 | ## Installation 15 | 16 | ### To Build It Yourself, 17 | If you'd like to inspect or customize the source code, follow the instructions in [BUILD.md](/src/BUILD.md) 18 | 19 | ### To Use Prebuilt Executable, 20 | 1. Download the `DynamicFPSLimiter_vX.X.X.zip` file from the latest release [here.](https://github.com/SameSalamander5710/DynamicFPSLimiter/releases) 21 | 2. Extract the zip file to a desired location 22 | 3. Run `DynamicFPSLimiter.exe` as Administrator. 23 | 4. **Recommended**: Add `DynamicFPSLimiter.exe`as an exclusion in RTSS to reduce the app's CPU performance overhead. 24 | - This can be done by holding the **Shift** key and clicking **Add** in RTSS, while the app is running. 25 | - **Note**: While not strictly necessary, this step is strongly recommended if you have disabled 'passive waiting' for the Global profile in RTSS 26 | 27 | Watch the demo here! (Based on v4.2.0) 28 | 29 | 30 | Watch the demo here! (Based on v4.2.0) 31 | 32 | 33 | 34 | Watch the demo here! (Based on v4.4.2) 35 | 36 | 37 | > [!NOTE] 38 | > - This app requires Rivatuner Statistics Server (RTSS) running in the background to function. Ensure RTSS is installed before running the app! 39 | > - Since RTSS runs with elevated privileges, DynamicFPSLimiter must also be run as Administrator to function fully. 40 | 41 | > [!CAUTION] 42 | > - The executable in the release was packaged using PyInstaller and may be flagged by some antivirus software as a Trojan. Updating to the [latest version](https://www.microsoft.com/en-us/wdsi/defenderupdates) of Windows Defender should prevent false detections. 43 | > - You can confirm whether the app is signed by me using the public certificate [here](/src/Public_SameSalamander5710.cer). 44 | > - You can find the VirusTotal report for the latest release (v4.4.2): 45 | > - [DynamicFPSLimiter_v4.4.2.zip](https://www.virustotal.com/gui/file/548be25493169765ab3a777ea878abc1934d46527fce629ec015a7a819e6c91b) 46 | 47 | ## The Concept 48 | This app was developed to enhance gaming experience in situations where the GPU load/demand varies greatly during a session. This is especially useful when using Lossless Scaling Frame Generation (LSFG). LSFG works best when the game runs with an FPS cap that leaves enough GPU headroom for frame generation. However, if GPU usage hits 100%—which may also cause the game’s base FPS to drop—you may experience input lag, which is undesirable. 49 | 50 | Typically, you have two ways to set an FPS cap: 51 | - Set a cap just below the average FPS – This works most of the time but can lead to input lag when FPS drops due to GPU saturation. 52 | - Set a cap well below the lowest observed FPS – This ensures stability but sacrifices frame rate in less demanding scenes. 53 | 54 | This app solves the issue by dynamically adjusting the base FPS limit in demanding areas, reducing input lag while still allowing higher frame rates in less intensive regions. As a result, you get a smoother and more responsive gaming experience without compromising too much performance. 55 | 56 | ## Disclaimer 57 | 58 | - This app is a personal project created for fun and is **not officially affiliated** with RTSS or Lossless Scaling. 59 | - As a hobby project, **updates and bug fixes may be delayed** or may not be provided regularly. 60 | 61 | ## Older versions 62 | 63 | For the older interactions or versions of the same idea, see: 64 | 1. [DynamicFPSLimiter v1.0](https://github.com/SameSalamander5710/DynamicFPSLimiter/tree/DFL_v1) 65 | 2. [DynamicFPSLimiter v2.0](https://github.com/SameSalamander5710/DynamicFPSLimiter/tree/DFL_v2) 66 | 3. [DynamicFPSLimiter v3.0](https://github.com/SameSalamander5710/DynamicFPSLimiter/tree/DFL_v3) 67 | 68 | ## License 69 | 70 | This project is currently licensed under the Apache License 2.0. See the [LICENSE](./LICENSE.txt) file for details. 71 | 72 | Previously licensed under the MIT License. The project was relicensed to Apache 2.0 on April 25, 2025 to provide clearer legal protections and attribution requirements. 73 | 74 | 75 | -------------------------------------------------------------------------------- /docs/HOW_IT_WORKS.md: -------------------------------------------------------------------------------- 1 | [Outdated information as of v4.0: Will not be updated due to constant changes in the underlying logic.] 2 | 3 | # How It Works 4 | 5 | The Dynamic FPS Limiter app dynamically adjusts the FPS cap in real-time based on GPU and CPU usage to optimize performance and reduce input lag. Below is an explanation of the logic used to determine when to increase or decrease the FPS cap. 6 | 7 | --- 8 | 9 | ## Key Variables 10 | - **GPU Usage Thresholds**: 11 | - `gpucutofffordecrease`: The GPU usage percentage above which the FPS cap is decreased. 12 | - `gpucutoffforincrease`: The GPU usage percentage below which the FPS cap is increased. 13 | 14 | - **CPU Usage Thresholds**: 15 | - `cpucutofffordecrease`: The CPU usage percentage above which the FPS cap is decreased. 16 | - `cpucutoffforincrease`: The CPU usage percentage below which the FPS cap is increased. 17 | 18 | - **Delays**: 19 | - `delaybeforedecrease`: The number of consecutive seconds the usage must exceed the decrease threshold before lowering the FPS cap. 20 | - `delaybeforeincrease`: The number of consecutive seconds the usage must stay below the increase threshold before raising the FPS cap. 21 | 22 | - **Other Parameters**: 23 | - `minvalidgpu` and `minvalidfps`: Minimum valid GPU usage and FPS values to prevent adjustments during loading screens or other low-performance states. 24 | 25 | --- 26 | 27 | ## Logic for Adjusting FPS Cap 28 | 29 | ### 1. **Decrease FPS Cap** 30 | The app decreases the FPS cap when: 31 | - GPU usage exceeds `gpucutofffordecrease` for at least `delaybeforedecrease` seconds, **or** 32 | - CPU usage exceeds `cpucutofffordecrease` for at least `delaybeforedecrease` seconds. 33 | 34 | If either condition is met: 35 | - The FPS cap is reduced by a multiple of `capstep`, calculated as: 36 | 37 | `X = ceil(((maxcap + CurrentFPSOffset) - fps_mean) / capstep)` 38 | 39 | - `X` ensures the FPS cap is reduced proportionally to the difference between the current FPS and the target FPS. `X` takes a minimum value of 1. 40 | - The new FPS cap is clamped to ensure it does not go below `mincap`. 41 | 42 | --- 43 | 44 | ### 2. **Increase FPS Cap** 45 | The app increases the FPS cap when: 46 | - GPU usage stays below `gpucutoffforincrease` for at least `delaybeforeincrease` seconds, **and** 47 | - CPU usage stays below `cpucutoffforincrease` for at least `delaybeforeincrease` seconds. 48 | 49 | If both conditions are met: 50 | - The FPS cap is increased by `capstep`. 51 | - The new FPS cap is clamped to ensure it does not exceed `maxcap`. 52 | 53 | --- 54 | 55 | ## Failsafe Mechanisms 56 | 1. **Ignore Low Usage**: 57 | - Adjustments are skipped if GPU usage is below `minvalidgpu` or FPS is below `minvalidfps`. This prevents adjustments during loading screens or other low-performance states. 58 | 59 | 2. **Clamping FPS Cap**: 60 | - The FPS cap is always clamped between `mincap` and `maxcap` to ensure stability. 61 | 62 | 3. **Delay Mechanism**: 63 | - The app uses `delaybeforedecrease` and `delaybeforeincrease` to avoid making rapid or unnecessary adjustments. This ensures the system has time to stabilize before changes are applied. 64 | 65 | --- 66 | 67 | ## Real-Time Monitoring 68 | - The app continuously monitors GPU and CPU usage, as well as the current FPS, in real-time. 69 | 70 | - **Polling Rate/Interval**: 71 | - The app collects GPU and CPU usage data at regular intervals, defined by `gpupollinginterval` and `cpupollinginterval` (in milliseconds), and stores them in rolling buffers. 72 | - For example, if `gpupollinginterval` is set to 100 ms, the app will query the GPU usage 10 times per second. 73 | - The polling interval determines how frequently the rolling buffers are updated with new data, upto a maximum length (e.g., 20 values for 2 second duration). 74 | 75 | - **Percentile Calculation**: 76 | - To make decisions based on trends rather than outliers, the app calculates the percentile of the usage data stored in the rolling buffers. 77 | - The `gpupercentile` and `cpupercentile` settings define the percentile to be calculated (e.g., 70th percentile). 78 | - The percentile calculation ensures that the app reacts to sustained usage patterns rather than brief spikes or dips in usage. 79 | - For example, if the 70th percentile of GPU usage is 85%, it means that 70% of the recorded usage values are below 85%, and 30% are above it. 80 | 81 | - **Adjustments Based on Trends**: 82 | - The app queries the percentile values every second and adds them to a new buffer. 83 | - These buffers are essentially lists that hold the most recent usage values for a fixed number of samples. 84 | - This is used to determine whether GPU or CPU usage has consistently `(delaybeforedecrease, delaybeforeincrease)` exceeded or fallen below the thresholds (`gpucutofffordecrease`, `gpucutoffforincrease`, `cpucutofffordecrease`, `cpucutoffforincrease`). 85 | - This approach ensures that adjustments to the FPS cap are based on sustained trends rather than transient fluctuations. 86 | 87 | By combining rolling buffers, polling intervals, and percentile calculations, the app achieves a balance between responsiveness and stability, ensuring that FPS cap adjustments are both timely and reliable. 88 | 89 | --- 90 | 91 | ## Summary 92 | The app dynamically adjusts the FPS cap to balance performance and responsiveness: 93 | - **Decrease FPS Cap**: When GPU or CPU usage is too high, the FPS cap is lowered to reduce system load. 94 | - **Increase FPS Cap**: When GPU and CPU usage are low, the FPS cap is raised to allow higher frame rates. 95 | 96 | This logic ensures a smoother and more responsive gaming experience while preventing GPU/CPU saturation. 97 | 98 | -------------------------------------------------------------------------------- /docs/Images/v4.1.0_2025-05-31-09-43-54.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/docs/Images/v4.1.0_2025-05-31-09-43-54.gif -------------------------------------------------------------------------------- /docs/Images/v4.1.0_2025-05-31-09-50-18.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/docs/Images/v4.1.0_2025-05-31-09-50-18.gif -------------------------------------------------------------------------------- /src/BUILD.md: -------------------------------------------------------------------------------- 1 | # Dynamic FPS Limiter 2 | 3 | ## Installation 4 | 5 | ### Prerequisites 6 | 1. Ensure Python is installed on your system (Python 3) 7 | 2. Install pip if not already installed: 8 | ```cmd 9 | python -m ensurepip --default-pip 10 | ``` 11 | 12 | ### Setting up Virtual Environment 13 | 1. Open a command prompt in the project directory 14 | 2. Create a virtual environment: 15 | ```cmd 16 | python -m venv venv 17 | ``` 18 | 3. Activate the virtual environment: 19 | ```cmd 20 | venv\Scripts\activate 21 | ``` 22 | 4. Install required packages: 23 | ```cmd 24 | pip install -r src/requirements.txt 25 | ``` 26 | 27 | ## Usage 28 | 29 | ### Running the Application 30 | To run the application directly without building an executable: 31 | 1. Activate the virtual environment if not already activated: 32 | ```cmd 33 | venv\Scripts\activate 34 | ``` 35 | 2. Run the application: 36 | ```cmd 37 | python src/__main__.py 38 | ``` 39 | Note: The application will automatically request administrator privileges when needed. 40 | 41 | ### Building an Executable 42 | To create a standalone executable: 43 | 1. Activate the virtual environment if not already activated: 44 | ```cmd 45 | venv\Scripts\activate 46 | ``` 47 | 2. Build the executable (no admin rights required): 48 | ```cmd 49 | python src/__main__.py --build 50 | ``` 51 | 3. The executable will be created in the `output/dist` directory. 52 | 53 | ### Notes 54 | - **Administrator Privileges**: Only required when running the application, not when building. 55 | - **Executable Location**: After building, find the executable in `output/dist`. It includes all dependencies. 56 | -------------------------------------------------------------------------------- /src/Public_SameSalamander5710.cer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/Public_SameSalamander5710.cer -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import argparse 4 | import PyInstaller.__main__ 5 | from ctypes import windll, byref, wintypes 6 | 7 | def is_admin(): 8 | """Check if the script is running with administrator privileges.""" 9 | try: 10 | return windll.shell32.IsUserAnAdmin() 11 | except: 12 | return False 13 | 14 | def relaunch_as_admin(): 15 | """Relaunch the script with administrator privileges.""" 16 | executable = sys.executable 17 | script = os.path.abspath(__file__) 18 | params = " ".join([f'"{arg}"' for arg in [script] + sys.argv[1:]]) 19 | result = windll.shell32.ShellExecuteW( 20 | None, "runas", executable, params, None, 1 21 | ) 22 | sys.exit() 23 | 24 | def run_app(): 25 | # Import and run DFL_v4 directly 26 | import core.DFL_v4 27 | 28 | def build_executable(): 29 | PyInstaller.__main__.run([ 30 | 'src/core/DFL_v4.py', 31 | '--onedir', 32 | '--uac-admin', 33 | '--clean', 34 | '--noconfirm', 35 | '--noconsole', 36 | '--name', 'DynamicFPSLimiter', 37 | '--icon', 'src/core/assets/DynamicFPSLimiter.ico', 38 | '--version-file', 'src/metadata/version.txt', 39 | '--add-data', 'src/core/assets/DynamicFPSLimiter.ico:assets', 40 | '--add-data', 'src/core/assets/DynamicFPSLimiter_red.ico:assets', 41 | '--add-data', 'src/core/assets/DynamicFPSLimiter_dark_green.ico:assets', 42 | '--add-data', 'src/core/assets/DynamicFPSLimiter_dark_red.ico:assets', 43 | '--add-data', 'src/core/assets/DynamicFPSLimiter_icon.png:assets', 44 | '--add-data', 'src/core/assets/close_button.png:assets', 45 | '--add-data', 'src/core/assets/minimize_button.png:assets', 46 | '--add-data', 'src/core/assets/faqs.csv:assets', 47 | '--distpath', 'output/dist', 48 | '--workpath', 'output/build' 49 | ]) 50 | 51 | if __name__ == '__main__': 52 | parser = argparse.ArgumentParser(description='Dynamic FPS Limiter') 53 | parser.add_argument('--build', action='store_true', help='Build executable') 54 | args = parser.parse_args() 55 | 56 | if args.build: 57 | print("Building executable...") 58 | build_executable() 59 | else: 60 | if not is_admin(): 61 | relaunch_as_admin() 62 | run_app() -------------------------------------------------------------------------------- /src/core/DFL_v4.py: -------------------------------------------------------------------------------- 1 | # DFL_v4.py 2 | # Dynamic FPS Limiter v4.4.2-patch1 3 | 4 | import ctypes 5 | ctypes.windll.shcore.SetProcessDpiAwareness(2) 6 | 7 | import dearpygui.dearpygui as dpg 8 | import threading 9 | import time 10 | import math 11 | import os 12 | import sys 13 | import csv 14 | from decimal import Decimal, InvalidOperation 15 | 16 | # tweak path so "src/" (or wherever your modules live) is on sys.path 17 | _this_dir = os.path.abspath(os.path.dirname(__file__)) 18 | _root = os.path.dirname(_this_dir) # Gets src directory 19 | if _root not in sys.path: 20 | sys.path.insert(0, _root) 21 | 22 | from core import logger 23 | from core.rtss_interface import RTSSInterface 24 | from core.cpu_monitor import CPUUsageMonitor 25 | from core.gpu_monitor import GPUUsageMonitor 26 | from core.themes import ThemesManager 27 | from core.config_manager import ConfigManager 28 | from core.tooltips import get_tooltips, add_tooltip, apply_all_tooltips, update_all_tooltip_visibility 29 | from core.warning import get_active_warnings 30 | from core.autostart import AutoStartManager 31 | from core.rtss_functions import RTSSController 32 | from core.fps_utils import FPSUtils 33 | from core.tray_functions import TrayManager 34 | from core.autopilot import autopilot_on_check, get_foreground_process_name 35 | 36 | # Default viewport size 37 | Viewport_width = 610 38 | Viewport_height = 700 39 | 40 | # Always get absolute path to EXE or script location 41 | Base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) 42 | rtss = RTSSController(logger) 43 | themes_manager = ThemesManager(Base_dir) 44 | cm = ConfigManager(logger, dpg, rtss, None, themes_manager, Base_dir) 45 | fps_utils = FPSUtils(cm, logger, dpg, Viewport_width) 46 | 47 | # Ensure the config folder exists in the parent directory of Base_dir 48 | parent_dir = os.path.dirname(Base_dir) 49 | 50 | # Paths to configuration files 51 | error_log_file = os.path.join(parent_dir, "error_log.txt") 52 | icon_path = os.path.join(Base_dir, 'assets/DynamicFPSLimiter.ico') 53 | faq_path = os.path.join(Base_dir, "assets/faqs.csv") 54 | 55 | app_title = "Dynamic FPS Limiter" 56 | 57 | logger.init_logging(error_log_file) 58 | rtss_manager = None 59 | 60 | questions = [] 61 | FAQs = {} 62 | 63 | with open(faq_path, newline='', encoding='utf-8') as csvfile: 64 | reader = csv.DictReader(csvfile) 65 | for idx, row in enumerate(reader, start=1): 66 | key = f"faq_{idx}" 67 | questions.append(row["question"]) 68 | FAQs[key] = row["answer"] 69 | 70 | def tooltip_checkbox_callback(sender, app_data, user_data): 71 | cm.update_preference_setting('showtooltip', sender, app_data, user_data) 72 | update_all_tooltip_visibility(dpg, app_data, get_tooltips(), cm, logger) 73 | 74 | def autostart_checkbox_callback(sender, app_data, user_data): 75 | cm.update_preference_setting('launchonstartup', sender, app_data, user_data) 76 | 77 | is_checked = dpg.get_value("autostart_checkbox") 78 | if is_checked: 79 | autostart.create() 80 | else: 81 | autostart.delete() 82 | 83 | def autopilot_checkbox_callback(sender, app_data, user_data): 84 | cm.update_preference_setting('autopilot', sender, app_data, user_data) 85 | 86 | if cm.autopilot: 87 | dpg.configure_item("start_stop_button", enabled=False) 88 | else: 89 | dpg.configure_item("start_stop_button", enabled=True) 90 | 91 | running = False # Flag to control the monitoring loop 92 | 93 | cm.update_global_variables() 94 | 95 | def start_stop_callback(sender, app_data, user_data): 96 | 97 | cm = user_data 98 | 99 | global running 100 | running = not running 101 | dpg.configure_item("start_stop_button", label="Stop" if running else "Start") 102 | dpg.bind_item_theme("start_stop_button", themes_manager.themes["stop_button_theme"] if running else themes_manager.themes["start_button_theme"]) 103 | tray.set_running_state(running) 104 | cm.apply_current_input_values() 105 | 106 | # Reset variables to zero or their default state 107 | global fps_values, CurrentFPSOffset, fps_mean, gpu_values, cpu_values 108 | fps_values = [] 109 | CurrentFPSOffset = 0 110 | fps_mean = 0 111 | gpu_values = [] 112 | cpu_values = [] 113 | 114 | # Freeze input fields 115 | for key in cm.input_field_keys: 116 | dpg.configure_item(f"input_{key}", enabled=not running) 117 | 118 | for tag in cm.input_button_tags: 119 | dpg.configure_item(tag, enabled=not running) 120 | 121 | dpg.configure_item("profile_dropdown", enabled=not running) 122 | dpg.configure_item("autopilot_checkbox", enabled=not running) 123 | 124 | if running: 125 | 126 | time_series.clear() 127 | fps_time_series.clear() 128 | gpu_usage_series.clear() 129 | cpu_usage_series.clear() 130 | fps_series.clear() 131 | cap_series.clear() 132 | global elapsed_time 133 | elapsed_time = 0 # Reset elapsed time 134 | 135 | # Start threads 136 | monitoring_thread = threading.Thread(target=monitoring_loop, daemon=True) 137 | monitoring_thread.start() 138 | logger.add_log("Monitoring started") 139 | plotting_thread = threading.Thread(target=plotting_loop, daemon=True) 140 | plotting_thread.start() 141 | logger.add_log("Plotting started") 142 | else: 143 | reset_stats() 144 | CurrentFPSOffset = 0 145 | 146 | logger.add_log("Monitoring stopped") 147 | logger.add_log(f"Custom FPS limits: {cm.parse_decimal_set_to_string(fps_utils.current_stepped_limits())}") 148 | 149 | rtss.set_fractional_fps_direct(cm.current_profile, Decimal(max(fps_utils.current_stepped_limits()))) 150 | rtss.set_fractional_framerate(cm.current_profile, Decimal(max(fps_utils.current_stepped_limits()))) #To update GUI 151 | 152 | def reset_stats(): 153 | 154 | dpg.configure_item("gpu_usage_series", label="GPU: --") 155 | dpg.configure_item("cpu_usage_series", label="CPU: --") 156 | dpg.configure_item("fps_series", label="FPS: --") 157 | dpg.configure_item("cap_series", label="FPS Cap: --") 158 | time_series.clear() 159 | fps_time_series.clear() 160 | gpu_usage_series.clear() 161 | cpu_usage_series.clear() 162 | fps_series.clear() 163 | cap_series.clear() 164 | global elapsed_time 165 | elapsed_time = 0 166 | 167 | time_series = [] 168 | fps_time_series = [] 169 | gpu_usage_series = [] 170 | cpu_usage_series = [] 171 | fps_series = [] 172 | cap_series = [] 173 | max_points = 600 174 | elapsed_time = 0 # Global time updated by plotting_loop 175 | 176 | def update_plot_FPS(fps_val, cap_val): 177 | # Uses fps_time_series 178 | global fps_time_series, fps_series, cap_series, elapsed_time 179 | global max_points 180 | 181 | while len(fps_time_series) >= max_points: 182 | fps_time_series.pop(0) 183 | fps_series.pop(0) 184 | cap_series.pop(0) 185 | 186 | current_time = elapsed_time 187 | fps_time_series.append(current_time) 188 | fps_series.append(fps_val) 189 | cap_series.append(cap_val) 190 | 191 | dpg.set_value("fps_series", [fps_time_series, fps_series]) 192 | dpg.set_value("cap_series", [fps_time_series, cap_series]) 193 | 194 | fps_limit_list = fps_utils.current_stepped_limits() 195 | 196 | current_mincap = min(fps_limit_list) 197 | current_maxcap = max(fps_limit_list) 198 | min_ft = current_mincap - round((current_maxcap - current_mincap) * Decimal('0.1')) 199 | max_ft = current_maxcap + round((current_maxcap - current_mincap)* Decimal('0.1')) 200 | 201 | dpg.set_axis_limits("y_axis_right", min_ft, max_ft) 202 | 203 | def update_plot_usage(time_val, gpu_val, cpu_val): 204 | 205 | global time_series, gpu_usage_series, cpu_usage_series 206 | global max_points# Add needed globals 207 | 208 | while len(time_series) >= max_points: 209 | time_series.pop(0) 210 | gpu_usage_series.pop(0) 211 | cpu_usage_series.pop(0) 212 | 213 | gpu_val = gpu_val or 0 214 | cpu_val = cpu_val or 0 215 | 216 | # Append passed-in time and new values 217 | time_series.append(time_val) 218 | gpu_usage_series.append(gpu_val) 219 | cpu_usage_series.append(cpu_val) 220 | 221 | dpg.set_value("gpu_usage_series", [time_series, gpu_usage_series]) 222 | dpg.set_value("cpu_usage_series", [time_series, cpu_usage_series]) 223 | 224 | if time_series: 225 | start_x = time_series[0] 226 | end_x = time_series[-1] 227 | # Add a small buffer to the end time for better visibility 228 | dpg.set_axis_limits("x_axis", start_x, end_x + 1) 229 | 230 | # Update static lines to extend across the current X range of the main time_series 231 | # Ensure gpucutoff... variables are up-to-date globals 232 | dpg.set_value("line1", [[start_x, end_x + 1], [cm.gpucutofffordecrease, cm.gpucutofffordecrease]]) 233 | dpg.set_value("line2", [[start_x, end_x + 1], [cm.gpucutoffforincrease, cm.gpucutoffforincrease]]) 234 | else: 235 | dpg.set_axis_limits_auto("x_axis") 236 | 237 | fps_values = [] 238 | gpu_values = [] 239 | cpu_values = [] 240 | CurrentFPSOffset = 0 241 | fps_mean = 0 242 | 243 | def monitoring_loop(): 244 | global running, fps_values, CurrentFPSOffset, fps_mean, gpu_values, cpu_values 245 | global max_points 246 | 247 | last_process_name = None 248 | 249 | gpu_monitor.reinitialize() 250 | 251 | fps_limit_list = fps_utils.current_stepped_limits() 252 | 253 | current_mincap = min(fps_limit_list) 254 | current_maxcap = max(fps_limit_list) 255 | min_ft = current_mincap - round((current_maxcap - current_mincap) * Decimal('0.1')) 256 | max_ft = current_maxcap + round((current_maxcap - current_mincap) * Decimal('0.1')) 257 | 258 | increase_cooldown = 0 # Cooldown for increasing FPS cap 259 | 260 | while running: 261 | current_profile = cm.current_profile 262 | fps, process_name = rtss_manager.get_fps_for_active_window() 263 | #logger.add_log(f"Current highed CPU core load: {cpu_monitor.cpu_percentile}%") 264 | 265 | #logger.add_log(f"get_foreground_process_name {get_foreground_process_name()}") 266 | 267 | if cm.autopilot: 268 | selected_game = dpg.get_value("profile_dropdown") 269 | if selected_game != "Global" and get_foreground_process_name() != selected_game and running: 270 | start_stop_callback(None, None, cm) 271 | 272 | if process_name and process_name != last_process_name: 273 | last_process_name = process_name 274 | logger.add_log(f"Active window changed to: {last_process_name}") 275 | if process_name != "DynamicFPSLimiter.exe": 276 | dpg.set_value("LastProcess", last_process_name) 277 | gpu_monitor.reinitialize() 278 | 279 | if fps: 280 | if len(fps_values) > 2: 281 | fps_values.pop(0) 282 | fps_values.append(fps) 283 | fps_mean = sum(fps_values) / len(fps_values) 284 | 285 | gpuUsage = gpu_monitor.gpu_percentile 286 | if len(gpu_values) > (max(cm.delaybeforedecrease, cm.delaybeforeincrease)+1): 287 | gpu_values.pop(0) 288 | gpu_values.append(gpuUsage) 289 | 290 | cpuUsage = cpu_monitor.cpu_percentile 291 | if len(cpu_values) > (max(cm.delaybeforedecrease, cm.delaybeforeincrease)+1): 292 | cpu_values.pop(0) 293 | cpu_values.append(cpuUsage) 294 | 295 | # To prevent loading screens from affecting the fps cap 296 | if gpuUsage and process_name not in {"DynamicFPSLimiter.exe"}: 297 | if gpuUsage > cm.minvalidgpu and fps_mean > cm.minvalidfps: 298 | 299 | should_decrease = False 300 | gpu_decrease_condition = (len(gpu_values) >= cm.delaybeforedecrease and 301 | all(value >= cm.gpucutofffordecrease for value in gpu_values[-cm.delaybeforedecrease:])) 302 | cpu_decrease_condition = (len(cpu_values) >= cm.delaybeforedecrease and 303 | all(value >= cm.cpucutofffordecrease for value in cpu_values[-cm.delaybeforedecrease:])) 304 | if gpu_decrease_condition or cpu_decrease_condition: 305 | should_decrease = True 306 | 307 | if CurrentFPSOffset > (current_mincap - current_maxcap) and should_decrease: 308 | current_fps_cap = current_maxcap + CurrentFPSOffset 309 | try: 310 | # Find values lower than current fps_mean 311 | lower_values = [x for x in fps_limit_list if x < fps_mean] 312 | 313 | if lower_values: 314 | # If current cap is already lower than fps_mean 315 | if current_fps_cap <= fps_mean: 316 | # Get current index and move to next lower value 317 | current_index = fps_limit_list.index(current_fps_cap) 318 | if current_index < 0: 319 | next_fps = fps_limit_list[current_index - 1] 320 | CurrentFPSOffset = next_fps - current_maxcap 321 | rtss.set_fractional_framerate(current_profile, next_fps) 322 | else: 323 | # Jump to highest value below fps_mean 324 | next_fps = max(lower_values) 325 | CurrentFPSOffset = next_fps - current_maxcap 326 | rtss.set_fractional_framerate(current_profile, next_fps) 327 | except ValueError: 328 | # If current FPS not in list, find nearest lower value 329 | lower_values = [x for x in fps_limit_list if x < current_fps_cap] 330 | if lower_values: 331 | next_fps = max(lower_values) 332 | CurrentFPSOffset = next_fps - current_maxcap 333 | rtss.set_fractional_framerate(current_profile, next_fps) 334 | 335 | should_increase = False 336 | gpu_increase_condition = (len(gpu_values) >= cm.delaybeforeincrease and 337 | all(value <= cm.gpucutoffforincrease for value in gpu_values[-cm.delaybeforeincrease:])) 338 | cpu_increase_condition = (len(cpu_values) >= cm.delaybeforeincrease and 339 | all(value <= cm.cpucutoffforincrease for value in cpu_values[-cm.delaybeforeincrease:])) 340 | if gpu_increase_condition and cpu_increase_condition: 341 | should_increase = True 342 | 343 | # --- COOLDOWN LOGIC --- 344 | if increase_cooldown > 0: 345 | increase_cooldown -= 1 346 | 347 | if CurrentFPSOffset < 0 and should_increase and increase_cooldown == 0: 348 | current_fps = current_maxcap + CurrentFPSOffset 349 | gpu_range = cm.gpucutofffordecrease - cm.gpucutoffforincrease 350 | last_gpu = gpu_values[-1] if gpu_values else 0 351 | 352 | # Determine how many steps to increase 353 | steps = 1 354 | threshold = cm.gpucutoffforincrease - gpu_range 355 | while last_gpu < threshold and (threshold > cm.minvalidgpu): 356 | steps += 1 357 | threshold = cm.gpucutoffforincrease - gpu_range * steps 358 | 359 | try: 360 | current_index = fps_limit_list.index(current_fps) 361 | next_index = min(current_index + steps, len(fps_limit_list) - 1) 362 | if next_index > current_index: 363 | next_fps = fps_limit_list[next_index] 364 | CurrentFPSOffset = next_fps - current_maxcap 365 | rtss.set_fractional_framerate(current_profile, next_fps) 366 | increase_cooldown = cm.delaybeforeincrease # Start cooldown 367 | except ValueError: 368 | # If current FPS not in list, find nearest higher value 369 | higher_values = [x for x in fps_limit_list if x > current_fps] 370 | if higher_values: 371 | # Find the index of the smallest higher value 372 | min_higher = min(higher_values) 373 | min_higher_index = fps_limit_list.index(min_higher) 374 | next_index = min(min_higher_index + steps - 1, len(fps_limit_list) - 1) 375 | next_fps = fps_limit_list[next_index] 376 | CurrentFPSOffset = next_fps - current_maxcap 377 | rtss.set_fractional_framerate(current_profile, next_fps) 378 | increase_cooldown = cm.delaybeforeincrease # Start cooldown 379 | 380 | if running: 381 | # Update legend labels with current values 382 | dpg.configure_item("gpu_usage_series", label=f"GPU: {gpuUsage}%") 383 | if running and fps is not None: 384 | dpg.configure_item("fps_series", label=f"FPS: {fps:.1f}") 385 | dpg.configure_item("cap_series", label=f"FPS Cap: {current_maxcap + CurrentFPSOffset}") 386 | dpg.configure_item("cpu_usage_series", label=f"CPU: {cpuUsage}%") 387 | 388 | # Update plot if fps is valid 389 | if fps and process_name not in {"DynamicFPSLimiter.exe"}: 390 | # Scaling FPS value to fit 0-100 axis 391 | scaled_fps = ((fps - min_ft)/(max_ft - min_ft)) * Decimal('100') 392 | scaled_cap = ((Decimal(current_maxcap) + Decimal(CurrentFPSOffset) - min_ft)/(max_ft - min_ft)) * Decimal('100') 393 | actual_cap = current_maxcap + CurrentFPSOffset 394 | # Pass actual values, update_plot_FPS handles timing and lists 395 | update_plot_FPS(scaled_fps, scaled_cap) 396 | 397 | # Update last_process_name 398 | if process_name: 399 | last_process_name = process_name 400 | 401 | time.sleep(1) # This loop runs every 1 second 402 | 403 | def plotting_loop(): 404 | global running, elapsed_time # Make sure elapsed_time is global 405 | 406 | start_time = time.time() 407 | while running: 408 | # Calculate elapsed time SINCE start_time 409 | elapsed_time = time.time() - start_time 410 | 411 | gpuUsage = gpu_monitor.gpu_percentile 412 | cpuUsage = cpu_monitor.cpu_percentile 413 | 414 | # CALL update_plot_usage with the current time and usage values 415 | update_plot_usage(elapsed_time, gpuUsage, cpuUsage) 416 | 417 | time.sleep(math.lcm(cm.gpupollinginterval, cm.cpupollinginterval) / 1000.0) # Convert to seconds 418 | 419 | gui_running = True 420 | 421 | def gui_update_loop(): 422 | global gui_running, running 423 | 424 | while gui_running: 425 | 426 | # Wait until tray is not active 427 | while tray.is_tray_active and gui_running: 428 | time.sleep(1) # Sleep until tray is not active 429 | 430 | if not running: 431 | try: 432 | if fps_utils.current_stepped_limits(): 433 | warnings = get_active_warnings(dpg, cm, rtss_manager, int(min(fps_utils.current_stepped_limits()))) 434 | warning_visible = bool(warnings) 435 | warning_message = "\n".join(warnings) 436 | 437 | dpg.configure_item("warning_text", show=warning_visible) 438 | dpg.configure_item("warning_tooltip", show=warning_visible) 439 | dpg.set_value("warning_tooltip_text", warning_message) 440 | 441 | # Update FPS limit visualization based on current input values 442 | fps_utils.update_fps_cap_visualization() 443 | except Exception as e: 444 | if gui_running: # Only log if we're still supposed to be running 445 | logger.add_log(f"Error in GUI update loop: {e}") 446 | time.sleep(0.1) 447 | 448 | def autopilot_loop(): 449 | global gui_running, running 450 | while gui_running: 451 | if cm.autopilot and not running: 452 | autopilot_on_check(cm, rtss_manager, dpg, logger, running, start_stop_callback) 453 | time.sleep(1) # Tune interval as needed 454 | 455 | def exit_gui(): 456 | global running, gui_running, rtss_manager, monitoring_thread, plotting_thread 457 | 458 | gui_running = False 459 | running = False 460 | 461 | if cm.globallimitonexit: 462 | rtss.set_fractional_framerate("Global", Decimal(cm.globallimitonexit_fps)) 463 | 464 | if gpu_monitor: 465 | gpu_monitor.cleanup() 466 | if cpu_monitor: 467 | cpu_monitor.stop() 468 | if dpg.is_dearpygui_running(): 469 | dpg.destroy_context() 470 | 471 | tray = TrayManager( 472 | app_title, 473 | icon_path, 474 | on_restore=lambda: tray.restore_from_tray(), 475 | on_exit=exit_gui, 476 | viewport_width=Viewport_width, 477 | config_manager_instance=cm, # Pass ConfigManager instance 478 | hover_text=app_title, 479 | start_stop_callback=start_stop_callback, # Pass the callback 480 | fps_utils=fps_utils 481 | ) 482 | 483 | cm.tray = tray # Set tray manager in ConfigManager 484 | 485 | def toggle_luid_selection(): 486 | """ 487 | Wrapper function to prevent a 'gpu_monitor' not found error when DearPyGui builds the UI. 488 | 489 | This function is defined early so that it can be referenced as a callback in the UI layout, 490 | before the actual gpu_monitor instance is initialized later in the script. 491 | """ 492 | gpu_monitor.toggle_luid_selection() 493 | 494 | # Defining short sections of the GUI 495 | # TODO: Refactor main GUI into a separate module for better organization 496 | def build_profile_section(): 497 | with dpg.child_window(width=-1, height=145): 498 | with dpg.group(horizontal=True): 499 | #dpg.add_spacer(width=1) 500 | dpg.add_input_text(tag="game_name", multiline=False, readonly=True, width=260, height=10) 501 | #dpg.add_button(tag="game_name", label="", width=350) 502 | dpg.bind_item_theme("game_name", themes_manager.themes["no_padding_theme"]) 503 | # Use ThemesManager to bind font 504 | themes_manager.bind_font_to_item("game_name", "bold_font_large") 505 | 506 | dpg.add_checkbox(label="Autopilot", tag="autopilot_checkbox", 507 | default_value=cm.autopilot, 508 | callback=autopilot_checkbox_callback 509 | ) 510 | dpg.add_button(label="Detect Render GPU", callback=toggle_luid_selection, tag="luid_button", width=150) 511 | dpg.add_button(label="Start", tag="start_stop_button", callback=start_stop_callback, width=50, user_data=cm) 512 | dpg.bind_item_theme("start_stop_button", themes_manager.themes["start_button_theme"]) # Apply start button theme 513 | 514 | dpg.add_spacer(height=10) 515 | 516 | with dpg.table(header_row=False): 517 | dpg.add_table_column(init_width_or_weight=45) 518 | dpg.add_table_column(init_width_or_weight=100) 519 | dpg.add_table_column(init_width_or_weight=60) 520 | 521 | # First row 522 | with dpg.table_row(): 523 | dpg.add_text("Select Profile:") 524 | dpg.add_combo(tag="profile_dropdown", callback=cm.load_profile_callback, width=260, default_value="Global") 525 | dpg.add_button(label="Delete Profile", callback=cm.delete_selected_profile_callback, width=160) 526 | #TODO: Add toggle to delete in RTSS or not 527 | # Second row 528 | with dpg.table_row(): 529 | dpg.add_text("New RTSS Profile:") 530 | dpg.add_input_text(tag="new_profile_input", width=260) 531 | dpg.add_button(label="Add Profile", callback=cm.add_new_profile_callback, width=160) 532 | 533 | # Third row 534 | with dpg.table_row(): 535 | dpg.add_text("Last active process:") 536 | dpg.add_input_text(tag="LastProcess", multiline=False, readonly=True, width=260) 537 | dpg.bind_item_theme("LastProcess", themes_manager.themes["transparent_input_theme_2"]) 538 | dpg.add_button(tag="process_to_profile", label="Add process to Profiles", callback=cm.add_process_profile_callback, width=160) 539 | 540 | # GUI setup: Main Window 541 | dpg.create_context() 542 | themes_manager.create_themes() 543 | 544 | # Create fonts using ThemesManager 545 | fonts = themes_manager.create_fonts(logger) 546 | default_font = fonts.get("default_font") 547 | bold_font = fonts.get("bold_font") 548 | bold_font_large = fonts.get("bold_font_large") 549 | 550 | # Load image data 551 | close_image_path = os.path.join(Base_dir, "assets/close_button.png") 552 | minimize_image_path = os.path.join(Base_dir, "assets/minimize_button.png") 553 | icon_png_path = os.path.join(Base_dir, "assets/DynamicFPSLimiter_icon.png") 554 | close_width, close_height, close_channels, close_data = dpg.load_image(close_image_path) 555 | min_width, min_height, min_channels, min_data = dpg.load_image(minimize_image_path) 556 | icon_width, icon_height, icon_channels, icon_data = dpg.load_image(icon_png_path) 557 | 558 | # Create static textures 559 | with dpg.texture_registry(): 560 | close_texture = dpg.add_static_texture(close_width, close_height, close_data, tag="close_texture") 561 | minimize_texture = dpg.add_static_texture(min_width, min_height, min_data, tag="minimize_texture") 562 | icon_texture = dpg.add_static_texture(icon_width, icon_height, icon_data, tag="icon_texture") 563 | 564 | #The actual GUI starts here 565 | with dpg.window(label=app_title, tag="Primary Window"): 566 | 567 | # Title bar 568 | with dpg.group(horizontal=True): 569 | dpg.add_image(icon_texture, tag="icon", width=20, height=20) 570 | dpg.add_text(app_title, tag="app_title") 571 | #dpg.bind_item_font("app_title", bold_font) 572 | dpg.add_text("v4.4.2") 573 | dpg.add_spacer(width=310) 574 | 575 | dpg.add_image_button(texture_tag=minimize_texture, tag="minimize", callback=tray.minimize_to_tray, width=20, height=20) 576 | dpg.add_image_button(texture_tag=close_texture, tag="exit", callback=exit_gui, width=20, height=20) 577 | 578 | dpg.bind_item_theme("minimize", themes_manager.themes["titlebar_button_theme"]) 579 | dpg.bind_item_theme("exit", themes_manager.themes["titlebar_button_theme"]) 580 | 581 | with dpg.handler_registry(): 582 | dpg.add_mouse_drag_handler(button=0, threshold=0.0, callback=tray.drag_viewport) 583 | dpg.add_mouse_release_handler(callback=tray.on_mouse_release) 584 | dpg.add_mouse_click_handler(callback=tray.on_mouse_click) 585 | 586 | # Profiles 587 | dpg.add_spacer(height=1) 588 | build_profile_section() 589 | 590 | #Tabs 591 | tab_height = 130 592 | dpg.add_spacer(height=2) 593 | with dpg.tab_bar(): 594 | with dpg.tab(label=" Profile Settings", tag="tab1"): 595 | with dpg.child_window(height=tab_height, border=True): 596 | with dpg.group(horizontal=True): 597 | with dpg.group(width=205): 598 | with dpg.table(header_row=False, resizable=False, policy=dpg.mvTable_SizingFixedFit): 599 | dpg.add_table_column(width_fixed=True) # Column for labels 600 | dpg.add_table_column(width_fixed=True) # Column for input boxes 601 | for label, key in [("Max FPS limit:", "maxcap"), 602 | ("Min FPS limit:", "mincap"), 603 | ("Framerate ratio:", "capratio"), 604 | ("Framerate step:", "capstep")]: 605 | with dpg.table_row(): 606 | dpg.add_text(label, tag=f"label_{key}") 607 | dpg.add_input_int(tag=f"input_{key}", default_value=int(cm.settings[key]), 608 | width=90, step=1, step_fast=10, 609 | min_clamped=True, min_value=1) 610 | 611 | #2 dpg.add_spacer(width=1) 612 | with dpg.group(width=220): 613 | with dpg.table(header_row=False, resizable=False, policy=dpg.mvTable_SizingFixedFit): 614 | dpg.add_table_column(width_fixed=True) # Label 615 | with dpg.table_row(): 616 | with dpg.group(horizontal=True): 617 | dpg.add_button(label="GPU range:", tag="button_gpulimit", width=78) 618 | dpg.bind_item_theme("button_gpulimit", themes_manager.themes["button_right_theme"]) 619 | dpg.add_input_text(tag="input_gpucutoffforincrease", default_value=str(cm.settings["gpucutoffforincrease"]), width=40) 620 | dpg.add_text("-", wrap=300) 621 | dpg.add_input_text(tag="input_gpucutofffordecrease", default_value=str(cm.settings["gpucutofffordecrease"]), width=40) 622 | dpg.add_text("%", tag="gpu_percent_text", wrap=300) 623 | with dpg.table_row(): 624 | with dpg.group(horizontal=True): 625 | dpg.add_button(label="CPU range:", tag="button_cpulimit", width=78) 626 | dpg.bind_item_theme("button_cpulimit", themes_manager.themes["button_right_theme"]) 627 | dpg.add_input_text(tag="input_cpucutoffforincrease", default_value=str(cm.settings["cpucutoffforincrease"]), width=40) 628 | dpg.add_text("-", wrap=300) 629 | dpg.add_input_text(tag="input_cpucutofffordecrease", default_value=str(cm.settings["cpucutofffordecrease"]), width=40) 630 | dpg.add_text("%", tag="cpu_percent_text", wrap=300) 631 | with dpg.table_row(): 632 | with dpg.group(horizontal=True): 633 | dpg.add_button(label="FPS drop delay:", tag="button_delaybeforedecrease", width=110) 634 | dpg.bind_item_theme("button_delaybeforedecrease", themes_manager.themes["button_right_theme"]) 635 | dpg.add_input_int(tag=f"input_delaybeforedecrease", default_value=int(cm.settings["delaybeforedecrease"]), 636 | width=90, step=1, step_fast=10, 637 | min_clamped=True, min_value=1, max_value=99, max_clamped=True) 638 | with dpg.table_row(): 639 | with dpg.group(horizontal=True): 640 | dpg.add_button(label="FPS raise delay:", tag="button_delaybeforeincrease", width=110) 641 | dpg.bind_item_theme("button_delaybeforeincrease", themes_manager.themes["button_right_theme"]) 642 | dpg.add_input_int(tag=f"input_delaybeforeincrease", default_value=int(cm.settings["delaybeforeincrease"]), 643 | width=90, step=1, step_fast=10, 644 | min_clamped=True, min_value=1, max_value=99, max_clamped=True) 645 | #dpg.add_spacer(width=1) 646 | tab1_group3_width = 125 647 | with dpg.group(width=135): 648 | with dpg.table(header_row=False, resizable=False, policy=dpg.mvTable_SizingFixedFit): 649 | dpg.add_table_column(width_fixed=True) 650 | with dpg.table_row(): 651 | dpg.add_button(tag="quick_save", label="Quick Save", callback=cm.quick_save_settings, width=tab1_group3_width) 652 | with dpg.table_row(): 653 | dpg.add_button(tag="quick_load", label="Quick Load", callback=cm.quick_load_settings, width=tab1_group3_width) 654 | with dpg.table_row(): 655 | dpg.add_button(tag="Reset_Default", label="Reset to Default", callback=cm.reset_to_program_default, width=tab1_group3_width) 656 | with dpg.table_row(): 657 | dpg.add_button(tag="SaveToProfile", label="Save to Profile", callback=cm.save_to_profile, width=tab1_group3_width) 658 | dpg.bind_item_theme("SaveToProfile", themes_manager.themes["revert_gpu_theme"]) 659 | 660 | with dpg.tab(label=" Preferences", tag="tab2"): 661 | with dpg.child_window(height=tab_height): 662 | dpg.add_checkbox(label="Launch the app on Windows startup", tag="autostart_checkbox", 663 | default_value=cm.launchonstartup, callback=autostart_checkbox_callback) 664 | dpg.add_checkbox(label="Minimze on Launch", tag="minimizeonstartup_checkbox", 665 | default_value=cm.minimizeonstartup, 666 | callback=cm.make_update_preference_callback('minimizeonstartup') 667 | ) 668 | with dpg.group(horizontal=True): 669 | dpg.add_checkbox(label="Set", tag="profile_on_startup_checkbox", 670 | default_value=cm.profileonstartup, 671 | callback=cm.make_update_preference_callback('profileonstartup') 672 | ) 673 | dpg.add_button(label="Current Profile", tag="select_profile_button", 674 | callback=cm.select_default_profile_callback, width=105) 675 | dpg.add_text("as default on startup. Currently set to:") 676 | dpg.add_input_text(tag="profileonstartup_name", multiline=False, readonly=True, width=150, 677 | default_value=cm.profileonstartup_name) 678 | dpg.bind_item_theme("profileonstartup_name", themes_manager.themes["transparent_input_theme_2"]) 679 | with dpg.group(horizontal=True): 680 | dpg.add_checkbox(label="Reset RTSS Global Limit on Exit: ", tag="limit_on_exit_checkbox", 681 | default_value=cm.globallimitonexit, 682 | callback=cm.make_update_preference_callback('globallimitonexit') 683 | ) # instead of: lambda sender, app_data, user_data: cm.update_preference_setting('globallimitonexit', sender, app_data, user_data) 684 | dpg.add_input_int(tag="exit_fps_input", 685 | default_value=cm.globallimitonexit_fps, callback=cm.update_exit_fps_value, 686 | width=100, step=1, step_fast=10) 687 | dpg.add_checkbox(label="Show Tooltips", tag="tooltip_checkbox", 688 | default_value=cm.showtooltip, callback=tooltip_checkbox_callback) 689 | 690 | with dpg.tab(label=" Log", tag="tab3"): 691 | with dpg.child_window(tag="LogWindow", autosize_x=True, height=tab_height, border=True): 692 | #dpg.add_text("", tag="LogText", tracked = True, track_offset = 1.0) 693 | #2 dpg.add_spacer(height=2) 694 | dpg.add_input_text(tag="LogText", multiline=True, readonly=True, width=-1, height=110) 695 | 696 | dpg.bind_item_theme("LogText", themes_manager.themes["transparent_input_theme"]) 697 | 698 | # Refresh log display with any messages that were logged before GUI was ready 699 | logger.refresh_log_display() 700 | 701 | with dpg.tab(label=" FAQs", tag="tab4"): 702 | with dpg.child_window(height=tab_height): 703 | dpg.add_text("Frequently Asked Questions (FAQs): Hover for answers") 704 | #2 dpg.add_spacer(height=3) 705 | for question, (key, answer) in zip(questions, FAQs.items()): 706 | dpg.add_text(question, tag=key, bullet=True) 707 | with dpg.tooltip(parent=key, delay=0.5): 708 | dpg.add_text(answer, wrap=300) 709 | 710 | # Third Row: FPS lists and methods 711 | #dpg.add_spacer(height=5) 712 | #dpg.add_separator() 713 | dpg.add_spacer(height=5) 714 | with dpg.child_window(width=-1, height=125, border=True): 715 | with dpg.group(horizontal=True, width=-1): 716 | dpg.add_text("Method:") 717 | dpg.add_radio_button( 718 | items=["ratio", "step", "custom"], 719 | horizontal=True, 720 | callback=cm.current_method_callback, 721 | default_value="ratio",#settings["method"], 722 | tag="input_capmethod" 723 | ) 724 | dpg.bind_item_theme("input_capmethod", themes_manager.themes["radio_theme"]) 725 | #dpg.bind_item_font("input_capmethod", bold_font) 726 | dpg.add_text("Warning!", tag="warning_text", color=(190, 90, 90), 727 | pos=(500, 5), 728 | show=False) 729 | with dpg.tooltip(parent="warning_text", tag="warning_tooltip", show=False): 730 | dpg.add_text("", tag="warning_tooltip_text", wrap=300) 731 | dpg.bind_item_font("warning_text", bold_font) 732 | dpg.add_spacer(height=1) 733 | 734 | draw_height = 40 735 | layer1_height = 30 736 | layer2_height = 30 737 | draw_width = Viewport_width - 60 738 | margin = 10 739 | with dpg.drawlist(width= draw_width + 5, height=draw_height, tag="fps_cap_drawlist"): 740 | with dpg.draw_layer(tag="Baseline"): 741 | dpg.draw_line((margin, layer1_height // 2), (draw_width, layer1_height // 2), color=(200, 200, 200), thickness=2) 742 | with dpg.draw_layer(tag="Foreground"): 743 | dpg.draw_line((margin, layer2_height // 2), (draw_width, layer2_height // 2), color=(200, 200, 200), thickness=2) 744 | dpg.add_spacer(height=1) 745 | with dpg.group(horizontal=True): 746 | dpg.add_input_text( 747 | tag="input_customfpslimits", 748 | default_value=cm.settings["customfpslimits"], 749 | width=draw_width - 215, 750 | #pos=(10, 140), # Center the input horizontally 751 | callback=cm.sort_customfpslimits_callback, 752 | on_enter=True) 753 | dpg.add_button(label="Reset", tag="rest_fps_cap_button", width=80, callback=fps_utils.reset_custom_limits) 754 | dpg.add_button(label="Copy from above", tag="autofill_fps_caps", width=120, callback=fps_utils.copy_from_plot) 755 | 756 | # Fourth Row: Plot Section 757 | #dpg.add_spacer(height=5) 758 | #dpg.add_separator() 759 | #dpg.add_spacer(height=5) 760 | with dpg.child_window(tag = "plot_childwindow", width=-1, height=190, border=False): 761 | 762 | with dpg.plot(height=190, width=-1, tag="plot", no_menus=True, no_box_select=True, no_inputs=True): 763 | dpg.add_plot_axis(dpg.mvXAxis, label="Time (s)", tag="x_axis") 764 | dpg.add_plot_legend(location=dpg.mvPlot_Location_North, horizontal=True, 765 | no_highlight_item=True, no_highlight_axis=True, outside=True) 766 | 767 | # Left Y-axis for GPU Usage 768 | with dpg.plot_axis(dpg.mvYAxis, label="GPU/CPU Usage (%)", tag="y_axis_left", no_gridlines=True) as y_axis_left: 769 | dpg.add_line_series([], [], label="GPU: --", parent=y_axis_left, tag="gpu_usage_series") 770 | dpg.add_line_series([], [], label="CPU: --", parent=y_axis_left, tag="cpu_usage_series") 771 | # Add static horizontal dashed lines 772 | dpg.add_line_series([], [cm.gpucutofffordecrease, cm.gpucutofffordecrease], parent=y_axis_left, tag="line1") 773 | dpg.add_line_series([], [cm.gpucutoffforincrease, cm.gpucutoffforincrease], parent=y_axis_left, tag="line2") 774 | 775 | # Right Y-axis for FPS 776 | with dpg.plot_axis(dpg.mvYAxis, label="FPS", tag="y_axis_right", no_gridlines=True) as y_axis_right: 777 | dpg.add_line_series([], [], label="FPS: --", parent=y_axis_right, tag="fps_series") 778 | dpg.add_line_series([], [], label="FPS Cap: --", parent=y_axis_right, tag="cap_series", segments=False) 779 | 780 | # Set axis limits 781 | dpg.set_axis_limits("y_axis_left", 0, 100) # GPU usage range 782 | min_ft = cm.mincap - cm.capstep 783 | max_ft = cm.maxcap + cm.capstep 784 | dpg.set_axis_limits("y_axis_right", min_ft, max_ft) # FPS range 785 | 786 | # apply theme to series 787 | dpg.bind_item_theme("line1", themes_manager.themes["fixed_greyline_theme"]) 788 | dpg.bind_item_theme("line2", themes_manager.themes["fixed_greyline_theme"]) 789 | dpg.bind_item_theme("cap_series", themes_manager.themes["fps_cap_theme"]) 790 | 791 | viewport_x_pos, viewport_y_pos = TrayManager.get_centered_viewport_position(Viewport_width, Viewport_height) 792 | 793 | dpg.create_viewport(title="Dynamic FPS Limiter", 794 | width=Viewport_width, height=Viewport_height, 795 | resizable=False, decorated=False, x_pos=viewport_x_pos, y_pos=viewport_y_pos) 796 | dpg.set_viewport_resizable(False) 797 | dpg.set_viewport_max_width(Viewport_width) 798 | dpg.set_viewport_max_height(Viewport_height) 799 | dpg.set_viewport_small_icon(icon_path) 800 | dpg.setup_dearpygui() 801 | dpg.show_viewport() 802 | dpg.set_viewport_vsync(True) 803 | dpg.set_primary_window("Primary Window", True) 804 | 805 | # Setup and Run GUI 806 | logger.add_log("Initializing...") 807 | 808 | cm.update_profile_dropdown(select_first=True) 809 | cm.startup_profile_selection() 810 | 811 | gpu_monitor = GPUUsageMonitor(lambda: running, logger, dpg, themes_manager, interval=(cm.gpupollinginterval/1000), max_samples=cm.gpupollingsamples, percentile=cm.gpupercentile) 812 | cpu_monitor = CPUUsageMonitor(lambda: running, logger, dpg, interval=(cm.cpupollinginterval/1000), max_samples=cm.cpupollingsamples, percentile=cm.cpupercentile) 813 | 814 | # Assuming logger and dpg are initialized 815 | rtss.enable_limiter() 816 | 817 | rtss_manager = RTSSInterface(logger, dpg) 818 | 819 | gui_update_thread = threading.Thread(target=gui_update_loop, daemon=True) 820 | gui_update_thread.start() 821 | 822 | # Start the autopilot thread 823 | autopilot_thread = threading.Thread(target=autopilot_loop, daemon=True) 824 | autopilot_thread.start() 825 | 826 | apply_all_tooltips(dpg, get_tooltips(), cm.showtooltip, cm, logger) 827 | cm.current_method_callback() 828 | 829 | autostart = AutoStartManager(app_path=os.path.join(os.path.dirname(Base_dir), "DynamicFPSLimiter.exe")) 830 | autostart.update_if_needed(cm.launchonstartup) 831 | 832 | if cm.autopilot: 833 | dpg.configure_item("start_stop_button", enabled=False) 834 | 835 | dpg.bind_theme(themes_manager.themes["main_theme"]) 836 | dpg.bind_item_theme("plot_childwindow", themes_manager.themes["plot_bg_theme"]) 837 | 838 | logger.add_log("Initialized successfully.") 839 | 840 | #dpg.show_style_editor() 841 | #dpg.show_imgui_demo() 842 | 843 | dpg.set_frame_callback(1, lambda: tray.minimize_on_startup_if_needed(cm.minimizeonstartup)) 844 | 845 | dpg.start_dearpygui() -------------------------------------------------------------------------------- /src/core/assets/DynamicFPSLimiter.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/core/assets/DynamicFPSLimiter.ico -------------------------------------------------------------------------------- /src/core/assets/DynamicFPSLimiter_dark_green.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/core/assets/DynamicFPSLimiter_dark_green.ico -------------------------------------------------------------------------------- /src/core/assets/DynamicFPSLimiter_dark_red.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/core/assets/DynamicFPSLimiter_dark_red.ico -------------------------------------------------------------------------------- /src/core/assets/DynamicFPSLimiter_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/core/assets/DynamicFPSLimiter_icon.png -------------------------------------------------------------------------------- /src/core/assets/DynamicFPSLimiter_red.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/core/assets/DynamicFPSLimiter_red.ico -------------------------------------------------------------------------------- /src/core/assets/close_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/core/assets/close_button.png -------------------------------------------------------------------------------- /src/core/assets/faqs.csv: -------------------------------------------------------------------------------- 1 | question,answer 2 | Do I need to set profiles or framerate limits in RTSS for this app to work?,No. Clicking 'Start' will automatically create a profile and apply framerate limits as needed. 3 | Why does the app sometimes use high CPU?,Manually add the app as an exclusion (disabled application detection) in RTSS to reduce its CPU usage. 4 | How do I select a specific GPU as the render GPU?,Click 'Detect Render GPU' while the game is running. The GPU with the highest 3D engine usage will be selected. 5 | How do I add a running game process to Profiles?,Switch between the app and the game while monitoring is active. Then click 'Add process to profiles' to save the last active process. 6 | How do I disable CPU usage monitoring?,Set the CPU usage upper threshold to a value above 100 in the profile settings. 7 | How do I toggle visibility of plot elements?,Click the plot items in the legend to show or hide specific elements. 8 | What to do if I get slideshow-like behavior when using LS?,Keep the DynamicFPSLimiter app minimised to avoid conflicts. -------------------------------------------------------------------------------- /src/core/assets/minimize_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SameSalamander5710/DynamicFPSLimiter/26eb057ddf208a6248db91484a8153093eed13cb/src/core/assets/minimize_button.png -------------------------------------------------------------------------------- /src/core/autopilot.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import os 3 | 4 | def get_foreground_process_name(): 5 | """ 6 | Returns the process name of the currently focused (foreground) window. 7 | Works for any application, not just 3D apps. 8 | """ 9 | user32 = ctypes.windll.user32 10 | kernel32 = ctypes.windll.kernel32 11 | psapi = ctypes.windll.psapi 12 | 13 | hwnd = user32.GetForegroundWindow() 14 | if not hwnd: 15 | return None 16 | 17 | pid = ctypes.c_ulong() 18 | user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) 19 | process_id = pid.value 20 | if not process_id: 21 | return None 22 | 23 | PROCESS_QUERY_INFORMATION = 0x0400 24 | PROCESS_VM_READ = 0x0010 25 | h_process = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, process_id) 26 | if not h_process: 27 | return None 28 | 29 | exe_name = (ctypes.c_wchar * 260)() 30 | if psapi.GetModuleBaseNameW(h_process, None, exe_name, 260) == 0: 31 | kernel32.CloseHandle(h_process) 32 | return None 33 | 34 | kernel32.CloseHandle(h_process) 35 | return os.path.basename(exe_name.value) 36 | 37 | def autopilot_on_check(cm, rtss_manager, dpg, logger, running, start_stop_callback): 38 | """ 39 | Checks if the active process matches a profile and switches profile/running state if needed. 40 | """ 41 | if not (cm and rtss_manager and rtss_manager.is_rtss_running()): 42 | return 43 | 44 | result = rtss_manager.get_fps_for_active_window() 45 | if not result or len(result) < 2: 46 | return 47 | 48 | fps, process_name = result 49 | if not process_name: 50 | return 51 | 52 | profiles = cm.profiles_config.sections() if hasattr(cm, "profiles_config") else [] 53 | if process_name in profiles: 54 | # Switch profile in UI and config manager 55 | dpg.set_value("profile_dropdown", process_name) 56 | cm.load_profile_callback(None, process_name, None) 57 | if not running: 58 | logger.add_log(f"AutoPilot: Switched to profile '{process_name}' and started monitoring.") 59 | start_stop_callback(None, None, cm) # Call with expected arguments 60 | -------------------------------------------------------------------------------- /src/core/autostart.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import subprocess 4 | import dearpygui.dearpygui as dpg 5 | 6 | TASK_NAME = "DynamicFPSLimiter" 7 | 8 | class AutoStartManager: 9 | def __init__(self, app_path=None, task_name=TASK_NAME): 10 | self.app_path = app_path or self.get_current_app_path() 11 | self.task_name = task_name 12 | 13 | @staticmethod 14 | def get_current_app_path(): 15 | return os.path.abspath(sys.argv[0]) 16 | 17 | def task_exists(self): 18 | result = subprocess.run(f'schtasks /Query /TN "{self.task_name}"', 19 | shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) 20 | return result.returncode == 0 21 | 22 | def create(self): 23 | cmd = [ 24 | "schtasks", 25 | "/Create", 26 | "/SC", "ONLOGON", 27 | "/TN", self.task_name, 28 | "/TR", f'"{self.app_path}"', 29 | "/RL", "HIGHEST", 30 | "/F" 31 | ] 32 | subprocess.run(" ".join(cmd), shell=True) 33 | 34 | def delete(self): 35 | if self.task_exists(): 36 | subprocess.run(f'schtasks /Delete /TN "{self.task_name}" /F', shell=True) 37 | 38 | def update_if_needed(self, startup_checkbox): 39 | if startup_checkbox: 40 | if self.task_exists(): 41 | result = subprocess.run(f'schtasks /Query /TN "{self.task_name}" /XML', 42 | shell=True, stdout=subprocess.PIPE, text=True) 43 | if self.app_path.lower() not in result.stdout.lower(): 44 | self.delete() 45 | self.create() 46 | else: 47 | self.create() 48 | else: 49 | if self.task_exists(): 50 | self.delete() 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/core/config_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | import dearpygui.dearpygui as dpg 4 | from decimal import Decimal, InvalidOperation 5 | 6 | class ConfigManager: 7 | def __init__(self, logger_instance, dpg_instance, rtss_instance, tray_instance, themes_manager, base_dir): 8 | self.logger = logger_instance 9 | self.dpg = dpg_instance 10 | self.rtss = rtss_instance 11 | self.tray = tray_instance 12 | self.themes = themes_manager.themes 13 | self.config_dir = os.path.join(os.path.dirname(base_dir), "config") 14 | os.makedirs(self.config_dir, exist_ok=True) 15 | self.settings_path = os.path.join(self.config_dir, "settings.ini") 16 | self.profiles_path = os.path.join(self.config_dir, "profiles.ini") 17 | self.Default_settings_original = { 18 | "maxcap": 114, 19 | "mincap": 40, 20 | "capratio": 10, 21 | "capstep": 5, 22 | "gpucutofffordecrease": 85, 23 | "gpucutoffforincrease": 70, 24 | 'cpucutofffordecrease': 105, 25 | 'cpucutoffforincrease': 101, 26 | "delaybeforedecrease": 2, 27 | "delaybeforeincrease": 10, 28 | "capmethod": "ratio", 29 | "customfpslimits": '30.01, 45.00, 59.99', 30 | "minvalidgpu": 14, 31 | "minvalidfps": 14, 32 | "globallimitonexit_fps": 98, 33 | 'cpupercentile': 70, 34 | 'cpupollinginterval': 100, 35 | 'cpupollingsamples': 20, 36 | 'gpupercentile': 70, 37 | 'gpupollinginterval': 100, 38 | 'gpupollingsamples': 20, 39 | 'profileonstartup_name': 'Global', 40 | } 41 | self.settings_config = configparser.ConfigParser() 42 | self.profiles_config = configparser.ConfigParser() 43 | self.load_or_init_configs() 44 | self.load_preferences() 45 | 46 | def load_or_init_configs(self): 47 | # Settings 48 | if os.path.exists(self.settings_path): 49 | self.settings_config.read(self.settings_path) 50 | else: 51 | self.settings_config["Preferences"] = { 52 | 'showtooltip': 'True', 53 | 'globallimitonexit': 'False', 54 | 'profileonstartup': 'True', 55 | 'launchonstartup': 'False', 56 | 'minimizeonstartup': 'False', 57 | 'autopilot': 'False', 58 | } 59 | self.settings_config["GlobalSettings"] = { 60 | 'minvalidgpu': '14', 61 | 'minvalidfps': '14', 62 | 'globallimitonexit_fps': '98', 63 | 'cpupercentile': '70', 64 | 'cpupollinginterval': '100', 65 | 'cpupollingsamples': '20', 66 | 'gpupercentile': '70', 67 | 'gpupollinginterval': '100', 68 | 'gpupollingsamples': '20', 69 | 'profileonstartup_name': 'Global', 70 | } 71 | with open(self.settings_path, 'w') as f: 72 | self.settings_config.write(f) 73 | # Profiles 74 | if os.path.exists(self.profiles_path): 75 | self.profiles_config.read(self.profiles_path) 76 | else: 77 | self.profiles_config["Global"] = { 78 | 'maxcap': '114', 79 | 'mincap': '40', 80 | 'capratio': '10', 81 | 'capstep': '5', 82 | 'gpucutofffordecrease': '85', 83 | 'gpucutoffforincrease': '70', 84 | 'cpucutofffordecrease': '105', 85 | 'cpucutoffforincrease': '101', 86 | 'delaybeforedecrease': '2', 87 | 'delaybeforeincrease': '10', 88 | 'capmethod': 'ratio', 89 | 'customfpslimits': '30.01, 45.00, 59.99', 90 | } 91 | with open(self.profiles_path, 'w') as f: 92 | self.profiles_config.write(f) 93 | 94 | self.input_field_keys = ["maxcap", "mincap", "capstep", "capratio", 95 | "gpucutofffordecrease", "gpucutoffforincrease", "cpucutofffordecrease", "cpucutoffforincrease", 96 | "capmethod", "customfpslimits", "delaybeforedecrease", "delaybeforeincrease"] 97 | 98 | self.input_button_tags = ["rest_fps_cap_button", "autofill_fps_caps", "quick_save", "quick_load", "Reset_Default", "SaveToProfile"] 99 | 100 | self.key_type_map = { 101 | "maxcap": int, 102 | "mincap": int, 103 | "capratio": int, 104 | "capstep": int, 105 | "gpucutofffordecrease": int, 106 | "gpucutoffforincrease": int, 107 | "cpucutofffordecrease": int, 108 | "cpucutoffforincrease": int, 109 | "capmethod": str, 110 | "customfpslimits": str, 111 | "delaybeforedecrease": int, 112 | "delaybeforeincrease": int, 113 | "minvalidgpu": int, 114 | "minvalidfps": int, 115 | "globallimitonexit_fps": int, 116 | "cpupercentile": int, 117 | "cpupollinginterval": int, 118 | "cpupollingsamples": int, 119 | "gpupercentile": int, 120 | "gpupollinginterval": int, 121 | "gpupollingsamples": int, 122 | 'showtooltip': bool, 123 | 'globallimitonexit': bool, 124 | 'profileonstartup': bool, 125 | 'profileonstartup_name': str, 126 | 'launchonstartup': bool, 127 | 'minimizeonstartup': bool, 128 | 'autopilot': bool, 129 | } 130 | 131 | self.current_profile = "Global" 132 | self.Default_settings = { 133 | key: self.get_setting(key, self.key_type_map.get(key, int)) 134 | for key in self.Default_settings_original 135 | } 136 | self.settings = self.Default_settings.copy() 137 | 138 | def load_preferences(self): 139 | for key in self.settings_config["Preferences"]: 140 | value = self.settings_config["Preferences"][key] 141 | value_type = self.key_type_map.get(key, str) 142 | if value_type is bool: 143 | value = str(value).strip().lower() == "true" 144 | else: 145 | value = value_type(value) 146 | setattr(self, key, value) 147 | 148 | def parse_input_value(self, key, value): 149 | value_type = self.key_type_map.get(key, int) 150 | try: 151 | return value_type(value) 152 | except Exception: 153 | return value 154 | 155 | def parse_and_normalize_string_to_decimal_set(self, input_string): 156 | """Parse a comma-separated string into a set of unique Decimals normalized to max decimal places.""" 157 | values = [x.strip() for x in input_string.split(',') if x.strip()] 158 | 159 | if not values: 160 | self.logger.add_log("Input string is empty or contains only whitespace.") 161 | return [] 162 | 163 | try: 164 | decimal_set = {Decimal(x) for x in values} 165 | except InvalidOperation: 166 | self.logger.add_log("Invalid decimal in input string.") 167 | return [] 168 | sorted_decimals = sorted(decimal_set) 169 | 170 | # Determine max number of decimal places 171 | max_decimals = max( 172 | -d.normalize().as_tuple().exponent if d.normalize().as_tuple().exponent < 0 else 0 173 | for d in sorted_decimals 174 | ) 175 | 176 | # Create the quantize pattern, e.g., Decimal('0.0001') for 4 decimal places 177 | quantize_pattern = Decimal(f"1.{'0' * max_decimals}") if max_decimals > 0 else Decimal("1") 178 | 179 | normalized_list = sorted({d.quantize(quantize_pattern) for d in sorted_decimals}) 180 | return normalized_list 181 | 182 | def parse_decimal_set_to_string(self, decimal_set): 183 | original_string = ', '.join(str(d) for d in decimal_set) 184 | return original_string 185 | 186 | def sort_customfpslimits_callback(self, sender, app_data, user_data): 187 | value = self.dpg.get_value("input_customfpslimits") 188 | try: 189 | sorted_limits = self.parse_and_normalize_string_to_decimal_set(value) 190 | sorted_str = ", ".join(str(x) for x in sorted_limits) 191 | self.dpg.set_value("input_customfpslimits", sorted_str) 192 | except Exception: 193 | pass 194 | 195 | # Function to get values with correct types 196 | def get_setting(self, key, value_type=None): 197 | """Get setting from appropriate config section based on key type.""" 198 | if value_type is None: 199 | value_type = self.key_type_map.get(key, str) 200 | # Get the raw value from the appropriate config section 201 | if key in self.settings_config["GlobalSettings"]: 202 | raw_value = self.settings_config["GlobalSettings"].get(key, self.Default_settings_original[key]) 203 | else: 204 | raw_value = self.profiles_config["Global"].get(key, self.Default_settings_original[key]) 205 | 206 | # Convert to the correct type 207 | if value_type is set: 208 | try: 209 | values = [] 210 | for x in str(raw_value).split(","): 211 | x = x.strip() 212 | if x.isdigit(): 213 | values.append(int(x)) 214 | else: 215 | self.logger.add_log(f"Warning: Skipped non-integer value '{x}' in key '{key}'") 216 | return set(values) 217 | except Exception: 218 | self.logger.add_log(f"Error parsing set for key '{key}', using default.") 219 | values = [] 220 | for x in str(self.Default_settings_original[key]).split(","): 221 | x = x.strip() 222 | if x.isdigit(): 223 | values.append(int(x)) 224 | return set(values) 225 | 226 | try: 227 | return value_type(raw_value) 228 | except Exception: 229 | try: 230 | return value_type(self.Default_settings_original[key]) 231 | except Exception: 232 | return self.Default_settings_original[key] 233 | def save_to_profile(self): 234 | selected_profile = dpg.get_value("profile_dropdown") 235 | 236 | if selected_profile: 237 | # Update profile-specific settings 238 | for key in self.input_field_keys: 239 | value = dpg.get_value(f"input_{key}") 240 | parsed_value = self.parse_input_value(key, value) 241 | # Store as string for config file 242 | self.profiles_config[selected_profile][key] = str(parsed_value) 243 | 244 | with open(self.profiles_path, "w") as configfile: 245 | self.profiles_config.write(configfile) 246 | 247 | self.logger.add_log(f"Settings saved to profile: {selected_profile}") 248 | 249 | def update_profile_dropdown(self, select_first=False): 250 | profiles = self.profiles_config.sections() 251 | dpg.configure_item("profile_dropdown", items=profiles) 252 | 253 | if select_first and profiles: 254 | dpg.set_value("profile_dropdown", profiles[0]) # Set combo selection 255 | 256 | current_profile = dpg.get_value("profile_dropdown") 257 | dpg.set_value("game_name", current_profile) 258 | 259 | def load_profile_callback(self, sender, app_data, user_data): 260 | 261 | self.current_profile = app_data 262 | profile_name = app_data 263 | 264 | if profile_name not in self.profiles_config: 265 | return 266 | for key in self.input_field_keys: 267 | value = self.profiles_config[profile_name].get(key, self.Default_settings_original[key]) 268 | parsed_value = self.parse_input_value(key, value) 269 | dpg.set_value(f"input_{key}", parsed_value) 270 | self.update_global_variables() 271 | dpg.set_value("new_profile_input", "") 272 | dpg.set_value("game_name", profile_name) 273 | #dpg.configure_item("game_name", label=profile_name) 274 | self.current_method_callback() # Update method-specific UI elements 275 | 276 | def save_profile(self, profile_name): 277 | self.profiles_config[profile_name] = {} 278 | # Save input fields 279 | for key in self.input_field_keys: 280 | value = dpg.get_value(f"input_{key}") 281 | parsed_value = self.parse_input_value(key, value) 282 | self.profiles_config[profile_name][key] = str(parsed_value) 283 | with open(self.profiles_path, 'w') as f: 284 | self.profiles_config.write(f) 285 | self.update_profile_dropdown() 286 | dpg.set_value("profile_dropdown", profile_name) 287 | self.load_profile_callback(None, profile_name, None) 288 | 289 | def add_new_profile_callback(self): 290 | new_name = dpg.get_value("new_profile_input") 291 | if new_name and new_name not in self.profiles_config: 292 | self.save_profile(new_name) 293 | dpg.set_value("new_profile_input", "") 294 | self.logger.add_log(f"New profile created: {new_name}") 295 | else: 296 | self.logger.add_log("Profile name is empty or already exists.") 297 | 298 | def add_process_profile_callback(self): 299 | new_name = dpg.get_value("LastProcess") 300 | if new_name and new_name not in self.profiles_config: 301 | self.save_profile(new_name) 302 | self.logger.add_log(f"New profile created: {new_name}") 303 | else: 304 | self.logger.add_log("Profile name is empty or already exists.") 305 | 306 | def delete_selected_profile_callback(self): 307 | 308 | profile_to_delete = dpg.get_value("profile_dropdown") 309 | if profile_to_delete == "Global": 310 | self.logger.add_log("Cannot delete the default 'Global' profile.") 311 | return 312 | if profile_to_delete in self.profiles_config: 313 | self.profiles_config.remove_section(profile_to_delete) 314 | self.rtss.delete_profile(profile_to_delete) 315 | with open(self.profiles_path, 'w') as f: 316 | self.profiles_config.write(f) 317 | self.update_profile_dropdown(select_first=True) 318 | 319 | # Reset input fields to the "Global" profile values 320 | if "Global" in self.profiles_config: 321 | for key in self.profiles_config["Global"]: 322 | try: 323 | value = self.profiles_config["Global"][key] 324 | parsed_value = self.parse_input_value(key, value) 325 | dpg.set_value(f"input_{key}", parsed_value) 326 | except Exception as e: 327 | self.logger.add_log(f"Error: Unable to convert value for key '{key}': {e}") 328 | self.update_global_variables() # Ensure global variables are updated 329 | else: 330 | self.logger.add_log("Error: 'Global' profile not found in configuration.") 331 | 332 | self.logger.add_log(f"Deleted profile: {profile_to_delete}") 333 | self.current_profile = "Global" 334 | self.current_method_callback() # Update method-specific UI elements 335 | 336 | # Function to sync settings with variables 337 | def update_global_variables(self): 338 | for key, value in self.settings.items(): 339 | #value_type = self.key_type_map.get(key, type(value)) 340 | if str(value).isdigit(): 341 | #globals()[key] = int(value) 342 | setattr(self, key, int(value)) 343 | else: 344 | #globals()[key] = value 345 | setattr(self, key, value) 346 | 347 | # Read values from UI input fields without modifying `settings` 348 | def apply_current_input_values(self): 349 | for key in self.input_field_keys: 350 | value = dpg.get_value(f"input_{key}") 351 | #globals()[key] = self.parse_input_value(key, value) 352 | setattr(self, key, self.parse_input_value(key, value)) 353 | 354 | def quick_save_settings(self): 355 | for key in self.input_field_keys: 356 | value = dpg.get_value(f"input_{key}") 357 | self.settings[key] = self.parse_input_value(key, value) 358 | self.update_global_variables() 359 | self.logger.add_log("Settings quick saved") 360 | 361 | def quick_load_settings(self): 362 | for key in self.input_field_keys: 363 | dpg.set_value(f"input_{key}", self.settings[key]) 364 | self.update_global_variables() 365 | self.logger.add_log("Settings quick loaded") 366 | self.current_method_callback() # Update method-specific UI elements 367 | 368 | def reset_to_program_default(self): 369 | 370 | for key in self.input_field_keys: 371 | dpg.set_value(f"input_{key}", self.Default_settings_original[key]) 372 | self.current_method_callback() # Update method-specific UI elements 373 | self.logger.add_log("Settings reset to program default") 374 | 375 | def startup_profile_selection(self): 376 | 377 | profile_name = self.settings_config["GlobalSettings"].get("profileonstartup_name", "Global") 378 | if self.profileonstartup: 379 | if profile_name in self.profiles_config: 380 | dpg.set_value("profile_dropdown", profile_name) 381 | self.load_profile_callback(None, profile_name, None) 382 | else: 383 | self.logger.add_log(f"Profile '{profile_name}' not found. Defaulting to 'Global'.") 384 | dpg.set_value("profile_dropdown", "Global") 385 | self.load_profile_callback(None, "Global", None) 386 | 387 | def current_method_callback(self, sender=None, app_data=None, user_data=None): 388 | 389 | method = app_data if app_data else dpg.get_value("input_capmethod") 390 | 391 | dpg.bind_item_theme("input_capratio", self.themes["enabled_text_theme"] if method == "ratio" else self.themes["disabled_text_theme"]) 392 | dpg.bind_item_theme("label_capratio", self.themes["enabled_text_theme"] if method == "ratio" else self.themes["disabled_text_theme"]) 393 | dpg.bind_item_theme("label_capstep", self.themes["enabled_text_theme"] if method == "step" else self.themes["disabled_text_theme"]) 394 | dpg.bind_item_theme("input_capstep", self.themes["enabled_text_theme"] if method == "step" else self.themes["disabled_text_theme"]) 395 | dpg.bind_item_theme("input_customfpslimits", self.themes["enabled_text_theme"] if method == "custom" else self.themes["disabled_text_theme"]) 396 | dpg.bind_item_theme("label_maxcap", self.themes["disabled_text_theme"] if method == "custom" else self.themes["enabled_text_theme"]) 397 | dpg.bind_item_theme("label_mincap", self.themes["disabled_text_theme"] if method == "custom" else self.themes["enabled_text_theme"]) 398 | dpg.bind_item_theme("input_maxcap", self.themes["disabled_text_theme"] if method == "custom" else self.themes["enabled_text_theme"]) 399 | dpg.bind_item_theme("input_mincap", self.themes["disabled_text_theme"] if method == "custom" else self.themes["enabled_text_theme"]) 400 | 401 | if self.tray: 402 | self.tray.update_hover_text() #Add max_fps if easy 403 | 404 | # self.tray.update_hover_text(self.tray.app_name, profile_name, method, self.tray.running) 405 | 406 | self.logger.add_log(f"Method selection changed: {method}") 407 | 408 | def update_preference_setting(self, key, sender, app_data, user_data): 409 | """ 410 | Generic method to update a boolean preference setting. 411 | key: The attribute and config key to update (e.g., 'launchonstartup'). 412 | """ 413 | setattr(self, key, app_data) 414 | self.settings_config["Preferences"][key] = str(app_data) 415 | with open(self.settings_path, 'w') as f: 416 | self.settings_config.write(f) 417 | self.logger.add_log(f"{key.replace('_', ' ').title()} set to: {getattr(self, key)}") 418 | 419 | def make_update_preference_callback(self, key): 420 | def callback(sender, app_data, user_data): 421 | self.update_preference_setting(key, sender, app_data, user_data) 422 | return callback 423 | 424 | def update_exit_fps_value(self, sender, app_data, user_data): 425 | 426 | new_value = app_data 427 | 428 | if isinstance(new_value, int) and new_value > 0: 429 | self.globallimitonexit_fps = new_value 430 | self.settings_config["GlobalSettings"]["globallimitonexit_fps"] = str(new_value) 431 | with open(self.settings_path, 'w') as f: 432 | self.settings_config.write(f) 433 | self.logger.add_log(f"Global Limit on Exit FPS value set to: {self.globallimitonexit_fps}") 434 | else: 435 | self.logger.add_log(f"Invalid value entered for Global Limit on Exit FPS: {app_data}. Reverting.") 436 | dpg.set_value(sender, self.globallimitonexit_fps) 437 | 438 | def select_default_profile_callback(self, sender, app_data, user_data): 439 | 440 | current_profile = dpg.get_value("profile_dropdown") 441 | dpg.set_value("profileonstartup_name", current_profile) 442 | self.settings_config["GlobalSettings"]["profileonstartup_name"] = current_profile 443 | with open(self.settings_path, 'w') as f: 444 | self.settings_config.write(f) 445 | self.logger.add_log(f"Profile on Startup set to: {self.profileonstartup_name}") -------------------------------------------------------------------------------- /src/core/cpu_monitor.py: -------------------------------------------------------------------------------- 1 | # cpu_monitor.py 2 | 3 | import time 4 | import threading 5 | import psutil 6 | import dearpygui.dearpygui as dpg 7 | 8 | class CPUUsageMonitor: 9 | def __init__(self, get_running, logger_instance, dpg_instance, interval=0.1, max_samples=20, percentile=70): 10 | self.interval = interval 11 | self.max_samples = max_samples 12 | self.samples = [] 13 | self.cpu_percentile = 0 14 | self._lock = threading.Lock() 15 | self.percentile = percentile 16 | self.logger = logger_instance 17 | self.dpg = dpg_instance 18 | self._running = get_running 19 | self.looping = True 20 | # Start background thread 21 | self._thread = threading.Thread(target=self.cpu_run, daemon=True) 22 | self._thread.start() 23 | self.logger.add_log(f"CPU monitoring started with interval: {round(self.interval*1000)} ms, max_samples: {self.max_samples}, percentile: {self.percentile}") 24 | 25 | def cpu_run(self): 26 | 27 | while self.looping: 28 | if self._running(): 29 | try: 30 | self.core_usages = psutil.cpu_percent(percpu=True) 31 | highest_usage = max(self.core_usages) 32 | 33 | 34 | with self._lock: 35 | self.samples.append(highest_usage) 36 | if len(self.samples) > self.max_samples: 37 | self.samples.pop(0) 38 | self.cpu_percentile = round(CPUUsageMonitor.calculate_percentile(self.samples, self.percentile)) 39 | #self.logger.add_log(f"CPU usage percentile: {self.cpu_percentile}%") 40 | except Exception as e: 41 | self.logger.add_log(f"CPU monitor error: {e}") 42 | 43 | time.sleep(self.interval) 44 | 45 | def stop(self): 46 | """Gracefully stop the background monitor thread.""" 47 | self.looping = False 48 | if self._thread.is_alive(): 49 | self._thread.join() 50 | 51 | def calculate_percentile(data: list, percentile: float) -> float: 52 | """ 53 | Calculate the percentile of a list of numbers. 54 | 55 | Args: 56 | data (list): The list of numbers. 57 | percentile (float): The desired percentile (0-100). 58 | 59 | Returns: 60 | float: The value at the specified percentile. 61 | """ 62 | if not data: 63 | raise ValueError("Data list is empty.") 64 | if not (0 <= percentile <= 100): 65 | raise ValueError("Percentile must be between 0 and 100.") 66 | 67 | # Sort the data 68 | sorted_data = sorted(data) 69 | 70 | # Calculate the index 71 | k = (len(sorted_data) - 1) * (percentile / 100.0) 72 | f = int(k) # Floor index 73 | c = f + 1 # Ceiling index 74 | 75 | if c >= len(data): 76 | return data[f] 77 | 78 | # If the index is an integer, return the value at that index 79 | if f == k: 80 | return sorted_data[f] 81 | 82 | # Otherwise, interpolate between the two closest values 83 | return sorted_data[f] + (k - f) * (sorted_data[c] - sorted_data[f]) -------------------------------------------------------------------------------- /src/core/fps_utils.py: -------------------------------------------------------------------------------- 1 | import dearpygui.dearpygui as dpg 2 | 3 | class FPSUtils: 4 | def __init__(self, cm, logger=None, dpg=None, viewport_width=610): 5 | self.cm = cm 6 | self.logger = logger 7 | self.dpg = dpg or dpg # fallback to global if not passed 8 | self.viewport_width = viewport_width 9 | self.last_fps_limits = [] 10 | 11 | def current_stepped_limits(self): 12 | maximum = int(dpg.get_value("input_maxcap")) 13 | minimum = int(dpg.get_value("input_mincap")) 14 | step = int(dpg.get_value("input_capstep")) 15 | ratio = int(dpg.get_value("input_capratio")) 16 | use_custom = dpg.get_value("input_capmethod") 17 | 18 | if use_custom == "custom": 19 | custom_limits = dpg.get_value("input_customfpslimits") 20 | if custom_limits and self.cm: 21 | try: 22 | custom_limits = self.cm.parse_and_normalize_string_to_decimal_set(custom_limits) 23 | return custom_limits 24 | except Exception as e: 25 | if self.logger: 26 | self.logger.add_log(f"Error parsing custom FPS limits: {e}") 27 | elif use_custom == "step": 28 | return self.make_stepped_values(maximum, minimum, step) 29 | elif use_custom == "ratio": 30 | return self.make_ratioed_values(maximum, minimum, ratio) 31 | 32 | def make_stepped_values(self, maximum, minimum, step): 33 | values = list(range(maximum, minimum - 1, -step)) 34 | if minimum not in values: 35 | values.append(minimum) 36 | return sorted(set(values)) 37 | 38 | def make_ratioed_values(self, maximum, minimum, ratio): 39 | values = [] 40 | current = maximum 41 | ratio_factor = 1 - (ratio / 100.0) 42 | if ratio_factor <= 0 or ratio_factor >= 1: 43 | return sorted(set([maximum, minimum])) 44 | prev_diff = None 45 | values.append(int(round(current))) 46 | 47 | while current >= minimum: 48 | current = current * ratio_factor 49 | rounded_current = int(round(current)) 50 | if len(values) >= 3: 51 | prev_diff = abs(values[-1] - values[-2]) 52 | if prev_diff is not None and abs(rounded_current - values[-1]) > prev_diff: 53 | rounded_current = values[-1] - prev_diff 54 | 55 | # Duplicate detection and correction 56 | while rounded_current in values and rounded_current > minimum: 57 | rounded_current -= 1 58 | 59 | values.append(rounded_current) 60 | current = rounded_current 61 | 62 | if rounded_current <= minimum: 63 | break 64 | if minimum not in values: 65 | values.append(minimum) 66 | custom_limits = sorted(x for x in set(values) if x >= minimum) 67 | return custom_limits 68 | 69 | def update_fps_cap_visualization(self): 70 | dpg = self.dpg 71 | Viewport_width = self.viewport_width 72 | 73 | fps_limits = self.current_stepped_limits() 74 | if not fps_limits or len(fps_limits) < 2: 75 | return 76 | 77 | if fps_limits == self.last_fps_limits: 78 | return 79 | 80 | self.last_fps_limits = fps_limits.copy() 81 | dpg.delete_item("Foreground") 82 | with dpg.draw_layer(tag="Foreground", parent="fps_cap_drawlist"): 83 | draw_width = Viewport_width - 67 84 | layer2_height = 30 85 | margin = 10 86 | min_fps = min(fps_limits) 87 | max_fps = max(fps_limits) 88 | fps_range = max_fps - min_fps 89 | for cap in fps_limits: 90 | x_pos = margin + int((cap - min_fps) / fps_range * (draw_width - margin)) 91 | y_pos = layer2_height // 2 92 | dpg.draw_circle( 93 | (x_pos, y_pos), 94 | 7, 95 | fill=(200, 200, 200), 96 | parent="Foreground" 97 | ) 98 | if len(fps_limits) < 20: 99 | dpg.draw_text((x_pos - 10, y_pos + 8), 100 | str(cap), 101 | color=(200, 200, 200), 102 | size=16, 103 | parent="Foreground") 104 | 105 | def copy_from_plot(self): 106 | fps_limits = sorted(set(self.current_stepped_limits())) 107 | fps_limits_str = ", ".join(str(int(round(x))) for x in fps_limits) 108 | self.dpg.set_value("input_customfpslimits", fps_limits_str) 109 | 110 | def reset_custom_limits(self): 111 | lowerlimit = self.dpg.get_value("input_mincap") 112 | upperlimit = self.dpg.get_value("input_maxcap") 113 | self.dpg.set_value("input_customfpslimits", f"{lowerlimit}, {upperlimit}") -------------------------------------------------------------------------------- /src/core/gpu_monitor.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import time 3 | from collections import defaultdict 4 | import re 5 | from typing import Optional, Dict, List, Tuple 6 | import threading 7 | 8 | pdh = ctypes.windll.pdh 9 | 10 | PDH_MORE_DATA = 0x800007D2 11 | PDH_FMT_DOUBLE = 0x00000200 12 | 13 | class PDH_FMT_COUNTERVALUE(ctypes.Structure): 14 | _fields_ = [("CStatus", ctypes.c_ulong), ("doubleValue", ctypes.c_double)] 15 | 16 | class GPUUsageMonitor: 17 | def __init__(self, get_running, logger_instance, dpg_instance, themes_instance, interval=0.1, max_samples=20, percentile=70): 18 | self.interval = interval 19 | self.max_samples = max_samples 20 | self.samples = [] 21 | self.gpu_percentile = 0 22 | self.percentile = percentile 23 | self.logger = logger_instance 24 | self.dpg = dpg_instance 25 | self.themes_manager = themes_instance 26 | self.query_handle = None 27 | self.counter_handles = {} 28 | self.instances = [] # Add this line 29 | self.initialize() 30 | self.luid_selected = False 31 | self.luid = "All" 32 | 33 | # Start background thread 34 | self._running = get_running 35 | self.looping = True 36 | self._lock = threading.Lock() 37 | self._thread = threading.Thread(target=self.gpu_run, daemon=True) 38 | self._thread.start() 39 | self.logger.add_log(f"GPU monitoring started with interval: {round(self.interval*1000)} ms, max_samples: {self.max_samples}, percentile: {self.percentile}") 40 | 41 | def initialize(self) -> None: 42 | """Initialize PDH query.""" 43 | self.query_handle = self._init_gpu_state() 44 | self.instances = self._setup_gpu_instances() # Store instances 45 | self.query_handle, self.counter_handles = self._setup_gpu_query_from_instances( 46 | self.query_handle, self.instances, "engtype_3D" 47 | ) 48 | if self.query_handle is None: 49 | raise RuntimeError("Query handle not set up.") 50 | 51 | def _init_gpu_state(self) -> ctypes.c_void_p: 52 | """Initialize PDH query and return the handle.""" 53 | query_handle = ctypes.c_void_p() 54 | status = pdh.PdhOpenQueryW(None, 0, ctypes.byref(query_handle)) 55 | 56 | if status != 0: 57 | raise RuntimeError(f"Failed to open PDH query. Error: {status}") 58 | 59 | return query_handle 60 | 61 | def _setup_gpu_instances(self) -> List[str]: 62 | """Set up GPU instances and return a list of them.""" 63 | counter_buf_size = ctypes.c_ulong(0) 64 | instance_buf_size = ctypes.c_ulong(0) 65 | 66 | pdh.PdhEnumObjectItemsW( 67 | None, None, "GPU Engine", 68 | None, ctypes.byref(counter_buf_size), 69 | None, ctypes.byref(instance_buf_size), 70 | 0, 0 71 | ) 72 | 73 | counter_buf = (ctypes.c_wchar * counter_buf_size.value)() 74 | instance_buf = (ctypes.c_wchar * instance_buf_size.value)() 75 | 76 | ret = pdh.PdhEnumObjectItemsW( 77 | None, None, "GPU Engine", 78 | counter_buf, ctypes.byref(counter_buf_size), 79 | instance_buf, ctypes.byref(instance_buf_size), 80 | 0, 0 81 | ) 82 | 83 | if ret != 0: 84 | raise RuntimeError(f"Failed to enumerate GPU Engine instances. Error: {ret}") 85 | 86 | instances = list(filter(None, instance_buf[:].split('\x00'))) 87 | if not instances: 88 | raise RuntimeError("No GPU engine instances found.") 89 | 90 | return instances 91 | 92 | def _setup_gpu_query_from_instances( 93 | self, 94 | query_handle: ctypes.c_void_p, 95 | instances: List[str], 96 | engine_type: str = "engtype_" 97 | ) -> Tuple[ctypes.c_void_p, Dict]: 98 | """Set up GPU query and counters from instances.""" 99 | counter_handles_by_luid = defaultdict(list) 100 | 101 | for inst in instances: 102 | if engine_type not in inst: 103 | continue 104 | 105 | match = re.search(r"luid_0x[0-9A-Fa-f]+_(0x[0-9A-Fa-f]+)", inst) 106 | if not match: 107 | continue 108 | 109 | luid = match.group(1) 110 | counter_path = f"\\GPU Engine({inst})\\Utilization Percentage" 111 | counter_handle = ctypes.c_void_p() 112 | 113 | status = pdh.PdhAddEnglishCounterW( 114 | query_handle, 115 | counter_path, 116 | None, 117 | ctypes.byref(counter_handle) 118 | ) 119 | 120 | if status == 0: 121 | counter_handles_by_luid[luid].append(counter_handle) 122 | else: 123 | raise RuntimeError(f"Failed to add counter: {counter_path}, status={status}") 124 | 125 | return query_handle, dict(counter_handles_by_luid) 126 | 127 | def get_gpu_usage(self, target_luid: Optional[str] = None, engine_type: str = "engtype_") -> Tuple[int, str]: 128 | 129 | self.initialize() 130 | temp_counter_handles = {} 131 | # Setup counters for the specified engine type 132 | _, temp_counter_handles = self._setup_gpu_query_from_instances( 133 | self.query_handle, self.instances, engine_type # Use stored instances 134 | ) 135 | 136 | pdh.PdhCollectQueryData(self.query_handle) 137 | time.sleep(0.1) 138 | pdh.PdhCollectQueryData(self.query_handle) 139 | 140 | usage_by_luid = {} 141 | handles_to_use = ( 142 | {target_luid: temp_counter_handles[target_luid]} 143 | if target_luid and target_luid in temp_counter_handles 144 | else temp_counter_handles 145 | ) 146 | 147 | for luid, handles in handles_to_use.items(): 148 | total = 0.0 149 | for h in handles: 150 | val = PDH_FMT_COUNTERVALUE() 151 | status = pdh.PdhGetFormattedCounterValue(h, PDH_FMT_DOUBLE, None, ctypes.byref(val)) 152 | if status == 0 and val.CStatus == 0: 153 | total += val.doubleValue 154 | else: 155 | raise RuntimeError(f"01_Failed to read counter (LUID: {luid}): status={status}") 156 | usage_by_luid[luid] = total 157 | 158 | if not usage_by_luid: 159 | return 0, "" 160 | 161 | max_luid, max_usage = max(usage_by_luid.items(), key=lambda item: item[1]) 162 | return int(max_usage), str(max_luid) 163 | 164 | def list_all_luids(self) -> List[str]: 165 | """ 166 | List all available GPU LUIDs. 167 | 168 | Returns: 169 | List[str]: List of GPU LUIDs found in the system 170 | """ 171 | if not self.counter_handles: 172 | raise RuntimeError("Counter handles are not set up.") 173 | 174 | return list(self.counter_handles.keys()) 175 | 176 | def cleanup(self) -> None: 177 | """Clean up PDH query handle.""" 178 | self.looping = False 179 | if self._thread.is_alive(): 180 | self._thread.join() 181 | if self.query_handle: 182 | pdh.PdhCloseQuery(self.query_handle) 183 | self.query_handle = None 184 | 185 | def gpu_run(self, engine_type: str = "engtype_3D"): 186 | 187 | # Setup counters for the specified engine type 188 | _, self.counter_handles = self._setup_gpu_query_from_instances( 189 | self.query_handle, self.instances, engine_type # Use stored instances 190 | ) 191 | 192 | pdh.PdhCollectQueryData(self.query_handle) 193 | 194 | while self.looping: 195 | time.sleep(self.interval) 196 | if self._running(): 197 | try: 198 | pdh.PdhCollectQueryData(self.query_handle) 199 | 200 | usage_by_luid = {} 201 | target_luid = self.luid 202 | handles_to_use = ( 203 | {target_luid: self.counter_handles[target_luid]} 204 | if target_luid and target_luid in self.counter_handles 205 | else self.counter_handles 206 | ) 207 | 208 | for luid, handles in handles_to_use.items(): 209 | total = 0.0 210 | #max_value = 0.0 211 | for h in handles: 212 | val = PDH_FMT_COUNTERVALUE() 213 | status = pdh.PdhGetFormattedCounterValue(h, PDH_FMT_DOUBLE, None, ctypes.byref(val)) 214 | if status == 0 and val.CStatus == 0: 215 | total += val.doubleValue 216 | #max_value = max(max_value, val.doubleValue) 217 | else: 218 | self.logger.add_log(f"02_Failed to read counter (LUID: {luid}): status={status}") 219 | self.reinitialize() 220 | usage_by_luid[luid] = total #max_value or total 221 | 222 | if not usage_by_luid: 223 | return 0, "" 224 | 225 | max_luid, max_usage = max(usage_by_luid.items(), key=lambda item: item[1]) 226 | #self.logger.add_log(f"target: {target_luid}, Current max LUID: {max_luid}, engine type: {engine_type}") 227 | highest_usage = max_usage 228 | 229 | with self._lock: 230 | self.samples.append(highest_usage) 231 | if len(self.samples) > self.max_samples: 232 | self.samples.pop(0) 233 | self.gpu_percentile = round(GPUUsageMonitor.calculate_percentile(self.samples, self.percentile)) 234 | #self.logger.add_log(f"GPU usage percentile: {self.gpu_percentile}%") 235 | 236 | except Exception as e: 237 | self.logger.add_log(f"GPU monitor error: {e}") 238 | 239 | def toggle_luid_selection(self): 240 | """ 241 | Toggle between tracking all GPUs and the most active LUID. 242 | Updates internal state and returns the new luid and selection state. 243 | """ 244 | if not self.luid_selected: 245 | # First click: detect top LUID 246 | usage, luid = self.get_gpu_usage(engine_type="engtype_3D") 247 | if luid: 248 | self.logger.add_log(f"Tracking LUID: {luid} | Current 3D engine Utilization: {usage}%") 249 | self.dpg.configure_item("luid_button", label="Revert to all GPUs") 250 | self.dpg.bind_item_theme("luid_button", self.themes_manager.themes["revert_gpu_theme"]) # Apply blue theme 251 | self.luid = luid 252 | self.luid_selected = True 253 | else: 254 | self.logger.add_log("Failed to detect active LUID.") 255 | else: 256 | # Second click: deselect 257 | self.luid = "All" 258 | self.logger.add_log("Tracking all GPU engines.") 259 | self.dpg.configure_item("luid_button", label="Detect Render GPU") 260 | self.dpg.bind_item_theme("luid_button", self.themes_manager.themes["detect_gpu_theme"]) # Apply default grey theme 261 | self.luid_selected = False 262 | return self.luid, self.luid_selected 263 | 264 | def reinitialize(self, engine_type: str = "engtype_3D"): 265 | self.logger.add_log("Reinitializing GPU monitor.") 266 | self.initialize() 267 | 268 | temp_counter_handles = {} 269 | # Setup counters for the specified engine type 270 | _, temp_counter_handles = self._setup_gpu_query_from_instances( 271 | self.query_handle, self.instances, engine_type # Use stored instances 272 | ) 273 | 274 | pdh.PdhCollectQueryData(self.query_handle) 275 | time.sleep(0.1) 276 | pdh.PdhCollectQueryData(self.query_handle) 277 | 278 | def calculate_percentile(data: list, percentile: float) -> float: 279 | """ 280 | Calculate the percentile of a list of numbers. 281 | 282 | Args: 283 | data (list): The list of numbers. 284 | percentile (float): The desired percentile (0-100). 285 | 286 | Returns: 287 | float: The value at the specified percentile. 288 | """ 289 | if not data: 290 | raise ValueError("Data list is empty.") 291 | if not (0 <= percentile <= 100): 292 | raise ValueError("Percentile must be between 0 and 100.") 293 | 294 | # Sort the data 295 | sorted_data = sorted(data) 296 | 297 | # Calculate the index 298 | k = (len(sorted_data) - 1) * (percentile / 100.0) 299 | f = int(k) # Floor index 300 | c = f + 1 # Ceiling index 301 | 302 | if c >= len(data): 303 | return data[f] 304 | 305 | # If the index is an integer, return the value at that index 306 | if f == k: 307 | return sorted_data[f] 308 | 309 | # Otherwise, interpolate between the two closest values 310 | return sorted_data[f] + (k - f) * (sorted_data[c] - sorted_data[f]) -------------------------------------------------------------------------------- /src/core/launch_popup.py: -------------------------------------------------------------------------------- 1 | import dearpygui.dearpygui as dpg 2 | import sys 3 | import os 4 | import ctypes 5 | 6 | ctypes.windll.shcore.SetProcessDpiAwareness(2) 7 | 8 | # Add the src directory to the Python path for imports 9 | _this_dir = os.path.abspath(os.path.dirname(__file__)) 10 | _src_dir = os.path.dirname(_this_dir) # Gets src directory 11 | if _src_dir not in sys.path: 12 | sys.path.insert(0, _src_dir) 13 | 14 | from core.themes import ThemesManager 15 | from core.tray_functions import TrayManager 16 | 17 | class PopupDragHandler: 18 | """Simple drag handler that uses TrayManager's drag functionality for popups.""" 19 | def __init__(self, viewport_width=420): 20 | # Create a minimal TrayManager instance just for drag functionality 21 | self.tray_manager = TrayManager( 22 | app_name="DynamicFPSLimiter", 23 | icon_path="", # Not needed for drag functionality 24 | on_restore=None, 25 | on_exit=None, 26 | viewport_width=viewport_width, 27 | config_manager_instance=None 28 | ) 29 | 30 | def on_mouse_click(self, sender, app_data, user_data): 31 | self.tray_manager.on_mouse_click(sender, app_data, user_data) 32 | 33 | def drag_viewport(self, sender, app_data, user_data): 34 | self.tray_manager.drag_viewport(sender, app_data, user_data) 35 | 36 | def on_mouse_release(self, sender, app_data, user_data): 37 | self.tray_manager.on_mouse_release(sender, app_data, user_data) 38 | 39 | def show_missing_rtss_popup(message="Could not find RTSSHooks64.dll. Please ensure RivaTuner Statistics Server is installed before running this app.", exit_callback=None, themes_manager=None): 40 | # Create drag handler for the popup 41 | drag_handler = PopupDragHandler(viewport_width=420) 42 | 43 | with dpg.window(label="Error", modal=True, no_close=True, tag="Primary Window"): 44 | # Split the message to handle "Error:" separately 45 | if message.startswith("Error:"): 46 | error_part = "Error:" 47 | rest_of_message = message[6:].strip() # Remove "Error:" and any following whitespace 48 | 49 | # Add "Error:" in bold 50 | error_text = dpg.add_text(error_part, wrap=400) 51 | if themes_manager: 52 | themes_manager.bind_font_to_item(error_text, "bold_font") 53 | 54 | # Add the rest of the message 55 | dpg.add_text(rest_of_message, wrap=400) 56 | else: 57 | dpg.add_text(message, wrap=400) 58 | 59 | # Add "Notice:" in bold 60 | notice_text = dpg.add_text("Notice:", wrap=400) 61 | if themes_manager: 62 | themes_manager.bind_font_to_item(notice_text, "bold_font") 63 | 64 | dpg.add_text("Please also ensure that the DynamicFPSLimiter app is downloaded from:", wrap=400) 65 | dpg.add_spacer(height=5) 66 | dpg.add_input_text(tag="notice_link", multiline=False, readonly=True, width=400) 67 | dpg.set_value("notice_link", "https://github.com/SameSalamander5710/DynamicFPSLimiter") 68 | 69 | # Apply blue text color to the link 70 | if themes_manager: 71 | with dpg.theme() as link_theme: 72 | with dpg.theme_component(dpg.mvInputText): 73 | dpg.add_theme_color(dpg.mvThemeCol_Text, (0, 150, 255, 255)) # Blue color 74 | dpg.bind_item_theme("notice_link", link_theme) 75 | 76 | dpg.add_spacer(height=5) 77 | dpg.add_text("This is currently the only official source of the app.") 78 | dpg.add_spacer(height=20) 79 | def _exit_app(): 80 | if exit_callback: 81 | exit_callback() 82 | else: 83 | dpg.destroy_context() 84 | sys.exit(1) 85 | with dpg.group(horizontal=True): 86 | dpg.add_spacer(width=132) 87 | dpg.add_button(label="Close", width=120, callback=lambda: _exit_app()) 88 | 89 | # Set up drag handlers for the popup 90 | with dpg.handler_registry(): 91 | dpg.add_mouse_click_handler(callback=drag_handler.on_mouse_click) 92 | dpg.add_mouse_drag_handler(callback=drag_handler.drag_viewport) 93 | dpg.add_mouse_release_handler(callback=drag_handler.on_mouse_release) 94 | 95 | def show_rtss_error_and_exit(rtss_path): 96 | """ 97 | Shows an RTSS error popup with full DearPyGui context creation and execution. 98 | This function handles the complete workflow and exits the application. 99 | """ 100 | # Get the base directory for themes manager (same as in DFL_v4.py) 101 | Base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) 102 | 103 | dpg.create_context() 104 | 105 | # Create themes and fonts for the popup 106 | themes_manager = ThemesManager(Base_dir) 107 | themes_manager.create_themes() 108 | fonts = themes_manager.create_fonts() 109 | 110 | show_missing_rtss_popup( 111 | f"Error: Could not find 'RTSSHooks64.dll' (or one of its dependencies). " 112 | "Please ensure RivaTuner Statistics Server is installed before running this app.\n\n", 113 | themes_manager=themes_manager 114 | ) 115 | 116 | # Apply the main theme to the popup 117 | dpg.bind_theme(themes_manager.themes["main_theme"]) 118 | 119 | # Calculate center position for the viewport 120 | viewport_width = 420 121 | viewport_height = 320 122 | x_pos, y_pos = TrayManager.get_centered_viewport_position(viewport_width, viewport_height) 123 | 124 | dpg.create_viewport(title="Dynamic FPS Limiter - Error", width=viewport_width, height=viewport_height, 125 | resizable=False, decorated=False, x_pos=x_pos, y_pos=y_pos) 126 | dpg.setup_dearpygui() 127 | dpg.show_viewport() 128 | dpg.set_primary_window("Primary Window", True) 129 | dpg.start_dearpygui() 130 | sys.exit(1) 131 | 132 | if __name__ == "__main__": 133 | # Test the popup by running this file directly using: python src\core\launch_popup.py 134 | print("Testing RTSS error popup...") 135 | 136 | # Get the base directory (same as in DFL_v4.py) 137 | Base_dir = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) 138 | 139 | # Create context and apply fonts/themes 140 | dpg.create_context() 141 | themes_manager = ThemesManager(Base_dir) 142 | themes_manager.create_themes() 143 | fonts = themes_manager.create_fonts() 144 | 145 | # Test with a sample RTSS path 146 | show_missing_rtss_popup( 147 | "Error: Could not find 'RTSSHooks64.dll' (or one of its dependencies). " 148 | "Please ensure RivaTuner Statistics Server is installed before running this app.\n\n", 149 | themes_manager=themes_manager 150 | ) 151 | 152 | # Apply theme 153 | dpg.bind_theme(themes_manager.themes["main_theme"]) 154 | 155 | # Calculate center position for the viewport 156 | viewport_width = 420 157 | viewport_height = 320 158 | x_pos, y_pos = TrayManager.get_centered_viewport_position(viewport_width, viewport_height) 159 | 160 | dpg.create_viewport(title="Dynamic FPS Limiter - Error", width=viewport_width, height=viewport_height, 161 | resizable=False, decorated=False, x_pos=x_pos, y_pos=y_pos) 162 | dpg.setup_dearpygui() 163 | dpg.show_viewport() 164 | dpg.set_primary_window("Primary Window", True) 165 | dpg.start_dearpygui() 166 | 167 | -------------------------------------------------------------------------------- /src/core/logger.py: -------------------------------------------------------------------------------- 1 | import dearpygui.dearpygui as dpg 2 | import logging 3 | import sys # Import sys module 4 | 5 | log_messages = [] 6 | 7 | # Function to initialize logging configuration and set the exception hook 8 | def init_logging(log_file_path): 9 | """Sets up basic logging configuration and assigns the system exception hook.""" 10 | logging.basicConfig( 11 | filename=log_file_path, # Use the provided path 12 | level=logging.ERROR, # Only log errors or more severe messages 13 | format='%(asctime)s - %(levelname)s - %(message)s' 14 | ) 15 | # Redirect uncaught exceptions to the error_log_exception function 16 | sys.excepthook = error_log_exception 17 | 18 | # Error logging function - now just logs the error 19 | def error_log_exception(exc_type, exc_value, exc_traceback): 20 | """Logs uncaught exceptions using the configured logger.""" 21 | # BasicConfig is now called in init_logging, so we just log here 22 | logging.error( 23 | "Uncaught exception", 24 | exc_info=(exc_type, exc_value, exc_traceback) 25 | ) 26 | 27 | # Keep the add_log function as it was 28 | def add_log(message): 29 | log_messages.insert(0, message) # Add message at the top 30 | log_messages[:] = log_messages[:50] # Keep only the latest 50 messages 31 | 32 | # Only update the GUI if the LogText widget exists 33 | try: 34 | if dpg.does_item_exist("LogText"): 35 | dpg.set_value("LogText", "\n".join(log_messages)) 36 | except Exception: 37 | # If there's any issue with the GUI update, just continue silently 38 | # The log messages are still stored in log_messages list 39 | pass 40 | 41 | def refresh_log_display(): 42 | """Refresh the log display widget with current messages.""" 43 | try: 44 | if dpg.does_item_exist("LogText"): 45 | dpg.set_value("LogText", "\n".join(log_messages)) 46 | except Exception: 47 | pass -------------------------------------------------------------------------------- /src/core/rtss_functions.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import os 3 | import winreg 4 | from decimal import Decimal, InvalidOperation 5 | from core.launch_popup import show_rtss_error_and_exit 6 | 7 | class RTSSController: 8 | RTSSHOOKSFLAG_LIMITER_DISABLED = 4 9 | 10 | def __init__(self, logger_instance): 11 | self.rtss_install_path = self.get_rtss_install_path() 12 | self.rtss_path = os.path.join(self.rtss_install_path, "RTSSHooks64.dll") 13 | self.logger = logger_instance 14 | try: 15 | self.dll = ctypes.WinDLL(self.rtss_path) 16 | except OSError as e: 17 | show_rtss_error_and_exit(self.rtss_path) 18 | self._setup_functions() 19 | 20 | def _setup_functions(self): 21 | self.LoadProfile = self.dll.LoadProfile 22 | self.LoadProfile.argtypes = [ctypes.c_char_p] 23 | self.LoadProfile.restype = None 24 | 25 | self.SaveProfile = self.dll.SaveProfile 26 | self.SaveProfile.argtypes = [ctypes.c_char_p] 27 | self.SaveProfile.restype = None 28 | 29 | self.GetProfileProperty = self.dll.GetProfileProperty 30 | self.GetProfileProperty.argtypes = [ctypes.c_char_p, ctypes.c_void_p, ctypes.c_uint] 31 | self.GetProfileProperty.restype = ctypes.c_bool 32 | 33 | self.SetProfileProperty = self.dll.SetProfileProperty 34 | self.SetProfileProperty.argtypes = [ctypes.c_char_p, ctypes.c_void_p, ctypes.c_uint] 35 | self.SetProfileProperty.restype = ctypes.c_bool 36 | 37 | self.DeleteProfile = self.dll.DeleteProfile 38 | self.DeleteProfile.argtypes = [ctypes.c_char_p] 39 | self.DeleteProfile.restype = None 40 | 41 | self.ResetProfile = self.dll.ResetProfile 42 | self.ResetProfile.argtypes = [ctypes.c_char_p] 43 | self.ResetProfile.restype = None 44 | 45 | self.UpdateProfiles = self.dll.UpdateProfiles 46 | self.UpdateProfiles.argtypes = [] 47 | self.UpdateProfiles.restype = None 48 | 49 | self.SetFlags = self.dll.SetFlags 50 | self.SetFlags.argtypes = [ctypes.c_uint, ctypes.c_uint] 51 | self.SetFlags.restype = ctypes.c_uint 52 | 53 | # self.GetFlags = self.dll.GetFlags 54 | # self.GetFlags.argtypes = [] 55 | # self.GetFlags.restype = ctypes.c_uint 56 | 57 | def get_rtss_install_path(self): 58 | try: 59 | key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Unwinder\RTSS") 60 | path, _ = winreg.QueryValueEx(key, "InstallPath") 61 | winreg.CloseKey(key) 62 | except FileNotFoundError: 63 | try: 64 | key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Unwinder\RTSS") 65 | path, _ = winreg.QueryValueEx(key, "InstallPath") 66 | winreg.CloseKey(key) 67 | except FileNotFoundError: 68 | path = r"C:\Program Files (x86)\RivaTuner Statistics Server" 69 | if path.lower().endswith("rtss.exe"): 70 | path = os.path.dirname(path) 71 | return path 72 | 73 | def delete_profile(self, profile_name): 74 | self.DeleteProfile(profile_name.encode('ascii')) 75 | 76 | def reset_profile(self, profile_name): 77 | self.ResetProfile(profile_name.encode('ascii')) 78 | 79 | def get_profile_property(self, profile_name, property_name, size=4): 80 | self.LoadProfile(profile_name.encode('ascii')) 81 | buf = (ctypes.c_byte * size)() 82 | success = self.GetProfileProperty(property_name.encode('ascii'), ctypes.byref(buf), size) 83 | if not success: 84 | return None 85 | return bytes(buf) 86 | 87 | def set_profile_property(self, profile_name, property_name, value, size=4, update=True): 88 | self.LoadProfile(profile_name.encode('ascii')) 89 | if isinstance(value, int): 90 | buf = ctypes.c_int(value) 91 | ptr = ctypes.byref(buf) 92 | elif isinstance(value, bytes): 93 | buf = (ctypes.c_byte * size).from_buffer_copy(value) 94 | ptr = ctypes.byref(buf) 95 | else: 96 | raise ValueError("Unsupported value type") 97 | success = self.SetProfileProperty(property_name.encode('ascii'), ptr, size) 98 | self.SaveProfile(profile_name.encode('ascii')) 99 | if update: 100 | self.UpdateProfiles() 101 | return success 102 | 103 | def create_profile(self, profile_name, properties): 104 | # Load global profile as a base 105 | self.LoadProfile(b"") 106 | # Set each property 107 | for prop, value in properties.items(): 108 | if isinstance(value, int): 109 | buf = ctypes.c_int(value) 110 | ptr = ctypes.byref(buf) 111 | size = ctypes.sizeof(buf) 112 | elif isinstance(value, bytes): 113 | size = len(value) 114 | buf = (ctypes.c_byte * size).from_buffer_copy(value) 115 | ptr = ctypes.byref(buf) 116 | else: 117 | raise ValueError("Unsupported value type") 118 | self.SetProfileProperty(prop.encode('ascii'), ptr, size) 119 | # Save as new profile 120 | self.SaveProfile(profile_name.encode('ascii')) 121 | self.UpdateProfiles() 122 | 123 | # def get_flags(self): 124 | # return self.GetFlags() 125 | 126 | def set_flags(self, and_mask, xor_mask): 127 | return self.SetFlags(and_mask, xor_mask) 128 | 129 | def disable_limiter(self): 130 | self.SetFlags(0xFFFFFFFF, self.RTSSHOOKSFLAG_LIMITER_DISABLED) 131 | self.UpdateProfiles() 132 | 133 | def enable_limiter(self): 134 | self.logger.add_log(f"Enabling RTSS limiter...") 135 | self.SetFlags(~self.RTSSHOOKSFLAG_LIMITER_DISABLED & 0xFFFFFFFF, 0) 136 | self.UpdateProfiles() 137 | 138 | # Derived functions 139 | def set_limit_denominator(self, profile_name, new_denominator, update=True): 140 | profiles_dir = os.path.join(self.rtss_install_path, "Profiles") 141 | if not profile_name or profile_name.lower() == "global": 142 | profile_file = os.path.join(profiles_dir, "Global") 143 | profile_name_for_api = "" 144 | else: 145 | profile_file = os.path.join(profiles_dir, f"{profile_name}.cfg") 146 | profile_name_for_api = profile_name 147 | 148 | if not os.path.isfile(profile_file): 149 | self.logger.add_log(f"Profile file not found: {profile_file}") 150 | return False 151 | 152 | with open(profile_file, "r", encoding="utf-8") as f: 153 | lines = f.readlines() 154 | 155 | found = False 156 | for i, line in enumerate(lines): 157 | if line.strip().startswith("LimitDenominator="): 158 | lines[i] = f"LimitDenominator={new_denominator}\n" 159 | found = True 160 | break 161 | 162 | if not found: 163 | lines.append(f"LimitDenominator={new_denominator}\n") 164 | 165 | with open(profile_file, "w", encoding="utf-8") as f: 166 | f.writelines(lines) 167 | 168 | #self.logger.add_log(f"Updated LimitDenominator to {new_denominator} in {profile_file}") 169 | if update: 170 | self.UpdateProfiles() 171 | return True 172 | 173 | def set_fractional_framerate(self, profile_name, framerate, update=False, denominator=False): 174 | profile_name_for_api = "" if not profile_name or profile_name.lower() == "global" else profile_name 175 | fr_str = str(framerate) 176 | if '.' in fr_str: 177 | decimals = len(fr_str.split('.')[1]) 178 | denominator = 10 ** decimals 179 | limit = int(round(float(framerate) * denominator)) 180 | else: 181 | denominator = 1 182 | limit = int(framerate) 183 | 184 | if denominator: 185 | self.set_limit_denominator(profile_name, denominator, update=update) 186 | 187 | self.set_profile_property(profile_name_for_api, "FramerateLimit", limit, update=update) 188 | if not update: 189 | self.UpdateProfiles() 190 | 191 | self.logger.add_log(f"Set {profile_name}: FramerateLimit={limit}, LimitDenominator={denominator} (actual limit: {limit/denominator})") 192 | return limit, denominator 193 | 194 | def set_fractional_fps_direct(self, profile_name, framerate, update=True): 195 | 196 | profiles_dir = os.path.join(self.rtss_install_path, "Profiles") 197 | if not profile_name or profile_name.lower() == "global": 198 | profile_file = os.path.join(profiles_dir, "Global") 199 | profile_name_for_api = "" 200 | else: 201 | profile_file = os.path.join(profiles_dir, f"{profile_name}.cfg") 202 | profile_name_for_api = profile_name 203 | 204 | if not os.path.isfile(profile_file): 205 | self.logger.add_log(f"Profile file not found: {profile_file}") 206 | return False 207 | 208 | fr_str = str(framerate) 209 | 210 | if '.' in fr_str: 211 | decimals = len(fr_str.split('.')[1]) 212 | denominator = 10 ** decimals 213 | limit = int(round(float(framerate) * denominator)) 214 | else: 215 | denominator = 1 216 | limit = int(framerate) 217 | 218 | with open(profile_file, "r", encoding="utf-8") as f: 219 | lines = f.readlines() 220 | 221 | found = False 222 | for i, line in enumerate(lines): 223 | stripped = line.strip() 224 | if stripped.startswith("Limit="): 225 | lines[i] = f"Limit={limit}\n" 226 | found_limit = True 227 | elif stripped.startswith("LimitDenominator="): 228 | lines[i] = f"LimitDenominator={denominator}\n" 229 | found_denominator = True 230 | 231 | # Append if not found 232 | if not found_limit: 233 | lines.append(f"Limit={limit}\n") 234 | if not found_denominator: 235 | lines.append(f"LimitDenominator={denominator}\n") 236 | 237 | with open(profile_file, "w", encoding="utf-8") as f: 238 | f.writelines(lines) 239 | 240 | #self.logger.add_log(f"Updated Limit={limit}, LimitDenominator={denominator} in {profile_file}") 241 | if update: 242 | self.UpdateProfiles() 243 | return True 244 | 245 | def get_framerate_limit(self, profile_name, get_denominator=False): 246 | profile_name_for_api = "" if not profile_name or profile_name.lower() == "global" else profile_name 247 | limit = self.get_profile_property(profile_name_for_api, "FramerateLimit", 4) 248 | if limit is None: 249 | return None 250 | 251 | limit_int = int.from_bytes(limit, byteorder='little', signed=True) 252 | 253 | if not get_denominator: 254 | return limit_int 255 | 256 | profiles_dir = os.path.join(self.rtss_install_path, "Profiles") 257 | if not profile_name or profile_name.lower() == "global": 258 | profile_file = os.path.join(profiles_dir, "Global") 259 | else: 260 | profile_file = os.path.join(profiles_dir, f"{profile_name}.cfg") 261 | 262 | denominator = 1 263 | if os.path.isfile(profile_file): 264 | with open(profile_file, "r", encoding="utf-8") as f: 265 | for line in f: 266 | if line.strip().startswith("LimitDenominator="): 267 | try: 268 | denominator = int(line.strip().split("=")[1]) 269 | except Exception: 270 | denominator = 1 271 | break 272 | 273 | if denominator < 1: 274 | denominator = 1 275 | 276 | return limit_int / denominator 277 | 278 | if __name__ == "__main__": 279 | rtss = RTSSController() 280 | test_profile = "test_profile 5.exe" 281 | 282 | #rtss.create_profile("test245345.exe") # this doesn not work without an additional parameter. 283 | 284 | # Set a framerate limit 285 | #print("Setting framerate limit to 60...") 286 | #rtss.set_profile_property(test_profile, "FramerateLimit", 65) 287 | 288 | # Get the framerate limit 289 | #limit = rtss.get_framerate_limit(test_profile) 290 | #print(f"Framerate limit for {test_profile}: {limit}") 291 | 292 | # Set a fractional framerate (e.g., 59.94) 293 | #print("Setting fractional framerate to 59.94...") 294 | #rtss.set_fractional_framerate(test_profile, 59.94) 295 | #limit_fractional = rtss.get_framerate_limit(test_profile, get_denominator=True) 296 | #print(f"Fractional framerate limit for {test_profile}: {limit_fractional}") 297 | 298 | # Clean up (optional) 299 | # rtss.delete_profile(test_profile) -------------------------------------------------------------------------------- /src/core/rtss_interface.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import psutil 3 | from ctypes import wintypes, WinDLL, byref 4 | import mmap 5 | import struct 6 | import time 7 | from collections import defaultdict 8 | import dearpygui.dearpygui as dpg 9 | import threading 10 | import os 11 | from decimal import Decimal, InvalidOperation 12 | 13 | user32 = WinDLL('user32', use_last_error=True) 14 | 15 | class RTSSInterface: 16 | def __init__(self, logger_instance, dpg_instance): 17 | """ 18 | Initializes the RTSS Interface. 19 | 20 | Args: 21 | logger_instance: An instance of the logger module/class. 22 | dpg_instance: The dearpygui instance (dpg). 23 | """ 24 | self.logger = logger_instance 25 | self.dpg = dpg_instance 26 | self.last_dwTime0s = defaultdict(int) 27 | 28 | def is_rtss_running(self): 29 | """Checks if RTSS.exe process is running.""" 30 | for process in psutil.process_iter(['name']): 31 | try: 32 | if process.info['name'] == 'RTSS.exe': 33 | return True 34 | except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): 35 | pass # Ignore processes that can't be accessed 36 | return False 37 | 38 | def _get_foreground_window_process_id(self): 39 | """Gets the process ID of the foreground window.""" 40 | hwnd = user32.GetForegroundWindow() 41 | if hwnd == 0: 42 | return None 43 | pid = wintypes.DWORD() 44 | user32.GetWindowThreadProcessId(hwnd, byref(pid)) 45 | return pid.value 46 | 47 | def get_fps_for_active_window(self): 48 | """Gets the FPS and process name for the active foreground window via RTSS shared memory.""" 49 | if not self.is_rtss_running(): 50 | return None, None 51 | 52 | process_id = self._get_foreground_window_process_id() 53 | if not process_id: 54 | return None, None 55 | 56 | try: 57 | # Initial size guess, might need resizing 58 | mmap_size = 4485160 59 | mm = mmap.mmap(0, mmap_size, 'RTSSSharedMemoryV2') 60 | dwSignature, dwVersion, dwAppEntrySize, dwAppArrOffset, dwAppArrSize, dwOSDEntrySize, dwOSDArrOffset, dwOSDArrSize, dwOSDFrame = struct.unpack('4sLLLLLLLL', mm[0:36]) 61 | calc_mmap_size = dwAppArrOffset + dwAppArrSize * dwAppEntrySize 62 | if mmap_size < calc_mmap_size: 63 | mm = mmap.mmap(0, calc_mmap_size, 'RTSSSharedMemoryV2') 64 | if dwSignature[::-1] not in [b'RTSS', b'SSTR'] or dwVersion < 0x00020000: 65 | return None, None # Invalid signature or version 66 | 67 | for dwEntry in range(0, dwAppArrSize): 68 | entry = dwAppArrOffset + dwEntry * dwAppEntrySize 69 | stump = mm[entry:entry + 6 * 4 + 260] 70 | if len(stump) == 0: 71 | continue 72 | dwProcessID, szName, dwFlags, dwTime0, dwTime1, dwFrames, dwFrameTime = struct.unpack('L260sLLLLL', stump) 73 | if dwProcessID == process_id: 74 | if dwTime0 > 0 and dwTime1 > 0 and dwFrames > 0: 75 | if dwTime0 != self.last_dwTime0s.get(dwProcessID): 76 | fps = 1000 * dwFrames / (dwTime1 - dwTime0) 77 | self.last_dwTime0s[dwProcessID] = dwTime0 78 | process_name = szName.decode(errors='ignore').rstrip('\x00') 79 | process_name = process_name.split('\\')[-1] 80 | return Decimal(fps), process_name 81 | return None, None 82 | except FileNotFoundError: 83 | # Shared memory doesn't exist (RTSS likely not running or OSD not enabled) 84 | self.rtss_status = False # Update status 85 | return None, None 86 | except Exception as e: 87 | self.logger.add_log(f"Error reading RTSS Shared Memory: {e}") 88 | return None, None 89 | 90 | return None, None # Process not found in shared memory 91 | -------------------------------------------------------------------------------- /src/core/themes.py: -------------------------------------------------------------------------------- 1 | import dearpygui.dearpygui as dpg 2 | import os 3 | 4 | bg_colour = (21, 20, 21, 255) 5 | bg_colour_1_transparent = (21, 20, 21, 0) 6 | bg_colour_2_child = (27, 31, 37, 255) 7 | #bg_colour_3_button = (35, 39, 47, 255) 8 | bg_colour_3_button = (43, 47, 57, 255) 9 | bg_colour_4_buttonhover = (32, 60, 68, 255) 10 | bg_colour_4_buttonhover_blue = (15, 73, 114, 255) 11 | bg_colour_5_buttonactive = (16, 63, 96, 255) # Blue 12 | #bg_colour_6_buttonstateactive_orange = (200, 88, 45, 255) 13 | bg_colour_7_text_faded = (150, 152, 161, 255) # Faded text for plot 14 | bg_colour_8_text_enabled = (255, 255, 255, 255) 15 | bg_colour_9_text_disabled = (150, 152, 161, 150) 16 | #bg_colour_10_button_disabled = (31, 35, 42, 255) 17 | 18 | class ThemesManager: 19 | def __init__(self, Base_dir): 20 | self.themes = {} 21 | self.base_dir = Base_dir 22 | self.fonts = {} 23 | 24 | # Font paths 25 | self.font_path = os.path.join(os.environ["WINDIR"], "Fonts", "segoeui.ttf") 26 | self.bold_font_path = os.path.join(os.environ["WINDIR"], "Fonts", "segoeuib.ttf") 27 | 28 | def create_themes(self): 29 | with dpg.theme() as main_theme: 30 | with dpg.theme_component(dpg.mvAll): 31 | dpg.add_theme_color(dpg.mvThemeCol_Separator, (0, 200, 255, 255)) # Cyan, RGBA 32 | dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 3.0) 33 | dpg.add_theme_color(dpg.mvThemeCol_Button, (51, 51, 55)) 34 | dpg.add_theme_style(dpg.mvStyleVar_WindowBorderSize, 0) 35 | dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, 1) 36 | dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 1) 37 | dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 10, 8) 38 | dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 8, 4) 39 | dpg.add_theme_style(dpg.mvStyleVar_TabBarBorderSize, 1) 40 | dpg.add_theme_style(dpg.mvStyleVar_TabRounding, 3) 41 | dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, 3) 42 | dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 6, 3) 43 | dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 8, 4) 44 | 45 | dpg.add_theme_color(dpg.mvThemeCol_Border, (255, 0, 0, 0)) 46 | dpg.add_theme_color(dpg.mvThemeCol_WindowBg, bg_colour) 47 | dpg.add_theme_color(dpg.mvThemeCol_ChildBg, bg_colour_2_child) 48 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, bg_colour_3_button) 49 | dpg.add_theme_color(dpg.mvThemeCol_FrameBgHovered, bg_colour_4_buttonhover) 50 | dpg.add_theme_color(dpg.mvThemeCol_FrameBgActive, bg_colour_5_buttonactive) 51 | dpg.add_theme_color(dpg.mvThemeCol_Button, bg_colour_3_button) 52 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, bg_colour_4_buttonhover) 53 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, bg_colour_5_buttonactive) 54 | dpg.add_theme_color(dpg.mvThemeCol_Tab, bg_colour_2_child) 55 | dpg.add_theme_color(dpg.mvThemeCol_TabActive, bg_colour_3_button) 56 | dpg.add_theme_color(dpg.mvThemeCol_TabHovered, bg_colour_4_buttonhover) 57 | dpg.add_theme_color(dpg.mvThemeCol_PopupBg, bg_colour) 58 | dpg.add_theme_color(dpg.mvThemeCol_HeaderHovered, bg_colour_4_buttonhover) 59 | dpg.add_theme_color(dpg.mvThemeCol_HeaderActive, bg_colour_5_buttonactive) 60 | dpg.add_theme_color(dpg.mvThemeCol_BorderShadow, (255, 255, 255, 11)) # Example: light shadow for 3D effect 61 | #dpg.add_theme_color(dpg.mvThemeCol_PopupActive, (30, 144, 255, 255)) 62 | 63 | # Plot-specific styles 64 | dpg.add_theme_style(dpg.mvPlotStyleVar_PlotBorderSize, 0, category=dpg.mvThemeCat_Plots) 65 | dpg.add_theme_style(dpg.mvPlotStyleVar_MinorAlpha, 0.20, category=dpg.mvThemeCat_Plots) 66 | dpg.add_theme_style(dpg.mvPlotStyleVar_MajorTickLen, 5, 5, category=dpg.mvThemeCat_Plots) 67 | dpg.add_theme_style(dpg.mvPlotStyleVar_LabelPadding, 5, 2, category=dpg.mvThemeCat_Plots) 68 | 69 | with dpg.theme_component(dpg.mvInputInt): 70 | dpg.add_theme_color(dpg.mvThemeCol_Border, (0, 0, 0, 11)) 71 | dpg.add_theme_color(dpg.mvThemeCol_BorderShadow, (0, 0, 0, 11)) 72 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, bg_colour) 73 | 74 | with dpg.theme_component(dpg.mvInputText): 75 | dpg.add_theme_color(dpg.mvThemeCol_Border, (0, 0, 0, 11)) 76 | dpg.add_theme_color(dpg.mvThemeCol_BorderShadow, (0, 0, 0, 11)) 77 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, bg_colour) 78 | 79 | #with dpg.theme_component(dpg.mvChildWindow): 80 | #dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (30, 30, 60, 255)) # Example: dark blue 81 | 82 | #dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 0.0, 1.0, category=dpg.mvThemeCat_Core) 83 | #dpg.add_theme_color(dpg.mvThemeCol_Button, (50, 150, 250)) # Button color 84 | #dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (70, 170, 255)) # Hover color 85 | #dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (90, 190, 255)) # Active color 86 | 87 | # Customize specific widget types 88 | #with dpg.theme_component(dpg.mvInputInt): 89 | 90 | #with dpg.theme_component(dpg.mvInputText): 91 | with dpg.theme_component(dpg.mvCheckbox): 92 | dpg.add_theme_color(dpg.mvThemeCol_CheckMark, (50, 150, 250)) 93 | 94 | with dpg.theme_component(dpg.mvTab): 95 | dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 8, 3) 96 | 97 | disabled_comps = [dpg.mvInputText, dpg.mvButton, dpg.mvTabBar, dpg.mvTab, dpg.mvImage, dpg.mvMenuBar, dpg.mvViewportMenuBar, dpg.mvMenu, dpg.mvMenuItem, dpg.mvChildWindow, dpg.mvGroup, dpg.mvDragFloatMulti, dpg.mvSliderFloat, dpg.mvSliderInt, dpg.mvFilterSet, dpg.mvDragFloat, dpg.mvDragInt, dpg.mvInputFloat, dpg.mvInputInt, dpg.mvColorEdit, dpg.mvClipper, dpg.mvColorPicker, dpg.mvTooltip, dpg.mvCollapsingHeader, dpg.mvSeparator, dpg.mvCheckbox, dpg.mvListbox, dpg.mvText, dpg.mvCombo, dpg.mvPlot, dpg.mvSimplePlot, dpg.mvDrawlist, dpg.mvWindowAppItem, dpg.mvSelectable, dpg.mvTreeNode, dpg.mvProgressBar, dpg.mvSpacer, dpg.mvImageButton, dpg.mvTimePicker, dpg.mvDatePicker, dpg.mvColorButton, dpg.mvFileDialog, dpg.mvTabButton, dpg.mvDrawNode, dpg.mvNodeEditor, dpg.mvNode, dpg.mvNodeAttribute, dpg.mvTable, dpg.mvTableColumn, dpg.mvTableRow] 98 | for comp_type in disabled_comps: 99 | with dpg.theme_component(comp_type, enabled_state=False): 100 | dpg.add_theme_color(dpg.mvThemeCol_Text, bg_colour_9_text_disabled) 101 | dpg.add_theme_color(dpg.mvThemeCol_Button, bg_colour_2_child) 102 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, bg_colour_2_child) 103 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, bg_colour_2_child) 104 | dpg.add_theme_color(dpg.mvThemeCol_CheckMark, (200, 200, 200)) # White selection circle 105 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, bg_colour_2_child) 106 | dpg.add_theme_color(dpg.mvThemeCol_BorderShadow, (255, 255, 255, 11)) 107 | 108 | with dpg.theme_component(dpg.mvRadioButton, enabled_state=False): 109 | dpg.add_theme_color(dpg.mvThemeCol_Text, bg_colour_9_text_disabled) 110 | dpg.add_theme_color(dpg.mvThemeCol_Button, bg_colour_2_child) 111 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, bg_colour_2_child) 112 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, bg_colour_2_child) 113 | dpg.add_theme_color(dpg.mvThemeCol_CheckMark, (200, 200, 200)) # White selection circle 114 | dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 18, 4) # Spacing between radio buttons 115 | dpg.add_theme_style(dpg.mvStyleVar_ItemInnerSpacing, 5, 4) # Spacing within the radio button 116 | self.themes["main_theme"] = main_theme 117 | 118 | with dpg.theme() as radio_theme: 119 | with dpg.theme_component(dpg.mvRadioButton): 120 | dpg.add_theme_color(dpg.mvThemeCol_CheckMark, (200, 200, 200)) # White selection circle 121 | dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 18, 4) # Spacing between radio buttons 122 | dpg.add_theme_style(dpg.mvStyleVar_ItemInnerSpacing, 5, 4) # Spacing within the radio button 123 | self.themes["radio_theme"] = radio_theme 124 | 125 | # RTSS running theme 126 | with dpg.theme() as start_button_theme: 127 | with dpg.theme_component(dpg.mvButton): 128 | dpg.add_theme_color(dpg.mvThemeCol_Button, (0, 100, 0)) 129 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (0, 120, 0)) 130 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (0, 140, 0)) 131 | self.themes["start_button_theme"] = start_button_theme 132 | 133 | # RTSS not running theme 134 | with dpg.theme() as stop_button_theme: 135 | with dpg.theme_component(dpg.mvButton): 136 | dpg.add_theme_color(dpg.mvThemeCol_Button, (170, 70, 70)) 137 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (190, 90, 90)) 138 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (220, 90, 90)) 139 | self.themes["stop_button_theme"] = stop_button_theme 140 | 141 | # Detect GPU theme 142 | with dpg.theme() as detect_gpu_theme: 143 | with dpg.theme_component(dpg.mvButton): 144 | #dpg.add_theme_color(dpg.mvThemeCol_Button, bg_colour_3_button) 145 | #dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, bg_colour_4_buttonhover_blue) 146 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, bg_colour_5_buttonactive) 147 | self.themes["detect_gpu_theme"] = detect_gpu_theme 148 | 149 | # Revert GPU theme 150 | with dpg.theme() as revert_gpu_theme: 151 | with dpg.theme_component(dpg.mvButton): 152 | dpg.add_theme_color(dpg.mvThemeCol_Button, bg_colour_5_buttonactive) 153 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, bg_colour_4_buttonhover_blue) 154 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, bg_colour_5_buttonactive) 155 | #with dpg.theme_component(dpg.mvAll): 156 | #dpg.add_theme_color(dpg.mvThemeCol_Text, bg_colour_8_text_stateactive) 157 | self.themes["revert_gpu_theme"] = revert_gpu_theme 158 | 159 | # Button right theme 160 | with dpg.theme() as button_right_theme: 161 | with dpg.theme_component(dpg.mvButton): 162 | dpg.add_theme_style(dpg.mvStyleVar_ButtonTextAlign, 1.00, category=dpg.mvThemeCat_Core) 163 | dpg.add_theme_color(dpg.mvThemeCol_Button, bg_colour_1_transparent, category=dpg.mvThemeCat_Core) 164 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, bg_colour_1_transparent, category=dpg.mvThemeCat_Core) 165 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, bg_colour_1_transparent, category=dpg.mvThemeCat_Core) 166 | dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) 167 | self.themes["button_right_theme"] = button_right_theme 168 | 169 | # Button right theme 170 | with dpg.theme() as titlebar_button_theme: 171 | with dpg.theme_component(dpg.mvImageButton): 172 | #dpg.add_theme_style(dpg.mvStyleVar_ButtonTextAlign, 0.50, 0.50, category=dpg.mvThemeCat_Core) 173 | dpg.add_theme_color(dpg.mvThemeCol_Button, bg_colour_1_transparent, category=dpg.mvThemeCat_Core) 174 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, bg_colour_4_buttonhover) 175 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, bg_colour_5_buttonactive) 176 | dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) 177 | dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 0) 178 | self.themes["titlebar_button_theme"] = titlebar_button_theme 179 | 180 | with dpg.theme() as no_padding_theme: 181 | with dpg.theme_component(dpg.mvAll): 182 | dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 0, 0) 183 | dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 0, 0) 184 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 0, 0, 0), category=dpg.mvThemeCat_Core) 185 | self.themes["no_padding_theme"] = no_padding_theme 186 | 187 | # Plot themes 188 | with dpg.theme() as fixed_greyline_theme: 189 | with dpg.theme_component(dpg.mvAll): 190 | dpg.add_theme_color(dpg.mvPlotCol_Line, (160, 160, 200, 255), category=dpg.mvThemeCat_Plots) 191 | self.themes["fixed_greyline_theme"] = fixed_greyline_theme 192 | 193 | with dpg.theme() as fps_cap_theme: 194 | with dpg.theme_component(dpg.mvAll): 195 | dpg.add_theme_color(dpg.mvPlotCol_Line, (128, 128, 128, 150), category=dpg.mvThemeCat_Plots) 196 | self.themes["fps_cap_theme"] = fps_cap_theme 197 | 198 | # Transparent input theme (for log window) 199 | with dpg.theme() as transparent_input_theme: 200 | with dpg.theme_component(dpg.mvInputText): 201 | dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) 202 | dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 0, category=dpg.mvThemeCat_Core) 203 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 0, 0, 0), category=dpg.mvThemeCat_Core) 204 | self.themes["transparent_input_theme"] = transparent_input_theme 205 | 206 | # Transparent input theme (for dynamic text display) 207 | with dpg.theme() as transparent_input_theme_2: 208 | with dpg.theme_component(dpg.mvInputText): 209 | #dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) 210 | #dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 0, category=dpg.mvThemeCat_Core) 211 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 0, 0, 0), category=dpg.mvThemeCat_Core) 212 | self.themes["transparent_input_theme_2"] = transparent_input_theme_2 213 | 214 | with dpg.theme() as plot_bg_theme: 215 | with dpg.theme_component(dpg.mvAll): 216 | dpg.add_theme_color(dpg.mvPlotCol_PlotBg, (0, 200, 255, 255)) # Example: cyan 217 | dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) 218 | dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (0, 200, 255, 0)) 219 | dpg.add_theme_color(dpg.mvThemeCol_PopupBg, (0, 200, 255, 0)) 220 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 200, 255, 0)) 221 | dpg.add_theme_color(dpg.mvThemeCol_Button, (0, 200, 255, 0)) 222 | dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (0, 200, 255, 0)) 223 | dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (0, 200, 255, 0)) 224 | dpg.add_theme_color(dpg.mvThemeCol_Text, bg_colour_7_text_faded) 225 | self.themes["plot_bg_theme"] = plot_bg_theme 226 | 227 | with dpg.theme() as disabled_text_theme: 228 | with dpg.theme_component(dpg.mvAll): 229 | dpg.add_theme_color(dpg.mvThemeCol_Text, bg_colour_9_text_disabled) 230 | self.themes["disabled_text_theme"] = disabled_text_theme 231 | 232 | with dpg.theme() as enabled_text_theme: 233 | with dpg.theme_component(dpg.mvAll): 234 | dpg.add_theme_color(dpg.mvThemeCol_Text, bg_colour_8_text_enabled) 235 | self.themes["enabled_text_theme"] = enabled_text_theme 236 | 237 | with dpg.theme() as warning_text_theme: 238 | with dpg.theme_component(dpg.mvAll): 239 | dpg.add_theme_color(dpg.mvThemeCol_Text, (190, 90, 90)) 240 | self.themes["warning_text_theme"] = warning_text_theme 241 | 242 | def create_fonts(self, logger=None): 243 | """ 244 | Create and register fonts for the application. 245 | Returns a dictionary of font references. 246 | """ 247 | try: 248 | with dpg.font_registry(): 249 | self.fonts["default_font"] = dpg.add_font(self.font_path, 18) 250 | self.fonts["bold_font"] = dpg.add_font(self.bold_font_path, 18) 251 | self.fonts["bold_font_large"] = dpg.add_font(self.bold_font_path, 24) 252 | 253 | # Bind the default font globally 254 | if self.fonts["default_font"]: 255 | dpg.bind_font(self.fonts["default_font"]) 256 | 257 | if logger: 258 | logger.add_log("Fonts loaded successfully") 259 | return self.fonts 260 | 261 | except Exception as e: 262 | if logger: 263 | logger.add_log(f"Failed to load system font: {e}") 264 | # Will use DearPyGui's default font as fallback 265 | return self.fonts 266 | 267 | def get_font(self, font_name): 268 | """Get a specific font by name""" 269 | return self.fonts.get(font_name, None) 270 | 271 | def bind_font_to_item(self, item_id, font_name): 272 | """Bind a font to a specific item""" 273 | font = self.get_font(font_name) 274 | if font: 275 | dpg.bind_item_font(item_id, font) 276 | return True 277 | return False -------------------------------------------------------------------------------- /src/core/tooltips.py: -------------------------------------------------------------------------------- 1 | # tooltips.py 2 | 3 | def get_tooltips(): 4 | return { 5 | "maxcap": "Defines the maximum FPS limit for the game. Hold CTRL for steps of 10.", 6 | "mincap": "Specifies the minimum FPS limit that may be reached. For optimal performance, set this to the lowest value you're comfortable with. Hold CTRL for steps of 10.", 7 | "capratio": "Percentage decrease used to generate FPS limits. Each limit is (100 - value)% of the previous one. Hold CTRL for steps of 10.", 8 | "capstep": "Increment size for adjusting the FPS cap. Smaller step sizes provide finer control. Hold CTRL for steps of 10.", 9 | "gpucutofffordecrease": "Sets the upper threshold for GPU usage. If GPU usage exceeds this value, the FPS cap will be lowered to maintain system performance.", 10 | "delaybeforedecrease": "Specifies how many times in a row GPU or CPU usage must exceed the upper threshold before the FPS cap begins to drop. Practically, this sets the delay (in seconds) before the limit is lowered.", 11 | "gpucutoffforincrease": "Defines the lower threshold for GPU usage. If GPU usage falls below this value, the FPS cap may increase to improve performance.", 12 | "delaybeforeincrease": "Specifies how many times in a row GPU and CPU usage must fall below the lower threshold before the FPS cap begins to rise. Practically, this sets the delay (in seconds) before the limit is raised.", 13 | "cpucutofffordecrease": "Sets the upper threshold for CPU usage. If CPU usage exceeds this value, the FPS cap will be lowered to maintain system performance.", 14 | "cpucutoffforincrease": "Defines the lower threshold for CPU usage. If CPU usage falls below this value, the FPS cap may increase to improve performance.", 15 | "minvalidgpu": "Sets the minimum valid GPU usage percentage required for adjusting the FPS. If the GPU usage is below this threshold, the FPS cap will not change. This helps prevent FPS fluctuations during loading screens.", 16 | "minvalidfps": "Defines the minimum valid FPS required for adjusting the FPS. If the FPS falls below this value, the FPS cap will not change. This helps prevent FPS fluctuations during loading screens.", 17 | "quick_save": "Save settings to memory temporarily. Useful to copy settings between profiles.", 18 | "quick_load": "Loads input values from memory. Useful to copy settings between profiles.", 19 | "start_stop_button": "Starts maintaining the FPS cap dynamically based on GPU/CPU utilization.", 20 | "luid_button": "Detects the render GPU based on CURRENT highest 3D engine utilization, and sets it as the target GPU for FPS limiting. Click again to deselect.", 21 | "exit_fps_input": "The specific FPS limit to apply globally when the application exits, if 'Set Global FPS Limit on Exit' is checked.", 22 | "SaveToProfile": "Saves the current settings to the selected profile. Settings are NOT saved automatically.", 23 | "Reset_Default": "Resets all settings to the program's default values. To reset to the profile's default values, reselect the profile from the profile dropdown.", 24 | "Reset_CustomFPSLimits": "Resets the custom FPS limits to 'max FPS limit' and 'min FPS limit'.", 25 | "DeleteProfile": "Deletes the selected profile. Be cautious, as this action cannot be undone.", 26 | "checkbox_globallimitonexit": "Enables or disables the application of a global FPS limit when exiting the program. When enabled, the specified FPS limit will be applied to all processes.", 27 | "autofill_fps_caps": "Click with the method set to 'ratio' or 'step' to copy the calculated limits.", 28 | "process_to_profile": "Add the current settings to a new profile based on the last used process.", 29 | "button_cpulimit": "(Optional) Set values below 100 to enable CPU-based FPS limiting.", 30 | "rest_fps_cap_button": "Clears the input fields and resets to Min/Max values", 31 | "autopilot_checkbox": "Relinquishes control of Start/Stop button to the autopilot, which will automatically shift to the corresponding profile based on the active process. It also Starts/Stops on its own. Only works for non-Global profiles.", 32 | } 33 | 34 | def add_tooltip(dpg, key, tooltips, ShowTooltip, cm, logger): 35 | """ 36 | Adds a tooltip to a widget using consistent naming convention. 37 | key: The key used in the tooltips dictionary 38 | """ 39 | if key in tooltips: 40 | # Determine the actual widget ID 41 | widget_id = f"input_{key}" if key in cm.input_field_keys else key 42 | 43 | # Only create tooltip if widget exists 44 | if dpg.does_item_exist(widget_id): 45 | tooltip_tag = f"{widget_id}_tooltip" 46 | with dpg.tooltip(parent=widget_id, tag=tooltip_tag, show=ShowTooltip, delay=0.5): 47 | dpg.add_text(tooltips[key], wrap=200) 48 | 49 | def apply_all_tooltips(dpg, tooltips, ShowTooltip, cm, logger): 50 | """Automatically adds tooltips to all widgets that have entries in the tooltips dictionary""" 51 | for key in tooltips: 52 | try: 53 | add_tooltip(dpg, key, tooltips, ShowTooltip, cm, logger) 54 | except Exception as e: 55 | logger.add_log(f"Failed to add tooltip for {key}: {e}") 56 | 57 | def update_all_tooltip_visibility(dpg, ShowTooltip, tooltips, cm, logger): 58 | """ 59 | Updates the visibility of all tooltips based on ShowTooltip value. 60 | """ 61 | for key in tooltips.keys(): 62 | parent_tag = f"input_{key}" if key in cm.input_field_keys else key 63 | tooltip_specific_tag = f"{parent_tag}_tooltip" 64 | 65 | if dpg.does_item_exist(tooltip_specific_tag): 66 | try: 67 | dpg.configure_item(tooltip_specific_tag, show=ShowTooltip) 68 | except SystemError as e: 69 | logger.add_log(f"Minor issue configuring tooltip '{tooltip_specific_tag}' for key '{key}': {e}") -------------------------------------------------------------------------------- /src/core/tray_functions.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import os 4 | import sys 5 | import ctypes 6 | from PIL import Image 7 | import dearpygui.dearpygui as dpg 8 | from pystray import Icon, MenuItem, Menu 9 | 10 | app_title = "Dynamic FPS Limiter" 11 | 12 | def get_hwnd_by_title(window_title): 13 | """ 14 | Returns the HWND (window handle) for a window with the given title. 15 | Returns None if not found. 16 | """ 17 | FindWindowW = ctypes.windll.user32.FindWindowW 18 | FindWindowW.restype = ctypes.c_void_p 19 | hwnd = FindWindowW(None, window_title) 20 | if hwnd == 0: 21 | return None 22 | return hwnd 23 | 24 | def hide_from_taskbar(): 25 | hwnd = get_hwnd_by_title(app_title) 26 | if hwnd: 27 | style = ctypes.windll.user32.GetWindowLongW(hwnd, -20) 28 | style = style & ~0x08 | 0x80 # Remove APPWINDOW, add TOOLWINDOW 29 | ctypes.windll.user32.SetWindowLongW(hwnd, -20, style) 30 | ctypes.windll.user32.ShowWindow(hwnd, 0) # SW_HIDE 31 | 32 | def show_to_taskbar(): 33 | hwnd = get_hwnd_by_title(app_title) 34 | if hwnd: 35 | style = ctypes.windll.user32.GetWindowLongW(hwnd, -20) 36 | style = style & ~0x80 | 0x08 # Remove TOOLWINDOW, add APPWINDOW 37 | ctypes.windll.user32.SetWindowLongW(hwnd, -20, style) 38 | ctypes.windll.user32.ShowWindow(hwnd, 5) # SW_SHOW 39 | 40 | def is_window_minimized(): 41 | """Returns True if the DearPyGui window is minimized (iconic), else False.""" 42 | hwnd = get_hwnd_by_title(app_title) 43 | if hwnd: 44 | return ctypes.windll.user32.IsIconic(hwnd) 45 | return False 46 | 47 | def get_mouse_screen_pos(): 48 | """Returns the mouse position as (x, y) in screen coordinates using Windows API.""" 49 | pt = ctypes.wintypes.POINT() 50 | ctypes.windll.user32.GetCursorPos(ctypes.byref(pt)) 51 | return (pt.x, pt.y) 52 | 53 | def is_left_mouse_button_down(): 54 | VK_LBUTTON = 0x01 55 | return (ctypes.windll.user32.GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0 56 | 57 | class TrayManager: 58 | def __init__(self, app_name, icon_path, on_restore, on_exit, viewport_width, config_manager_instance, hover_text=None, start_stop_callback=None, fps_utils=None): 59 | self.app_name = app_name 60 | self.icon_path = icon_path 61 | self.on_restore = on_restore 62 | self.on_exit = on_exit 63 | self.viewport_width = viewport_width 64 | self.cm = config_manager_instance 65 | self.hover_text = hover_text or app_name 66 | self.icon = None 67 | self.tray_thread = None 68 | self.is_tray_active = False 69 | self._dragging_viewport = False 70 | self._drag_start_mouse_pos = None 71 | self._drag_start_viewport_pos = None 72 | self.start_stop_callback = start_stop_callback 73 | self.running = False # Track running state for menu 74 | self.fps_utils = fps_utils 75 | 76 | @staticmethod 77 | def get_centered_viewport_position(viewport_width, viewport_height): 78 | """ 79 | Calculate the center position for a viewport based on screen dimensions. 80 | 81 | Args: 82 | viewport_width (int): Width of the viewport 83 | viewport_height (int): Height of the viewport 84 | 85 | Returns: 86 | tuple: (x_pos, y_pos) coordinates for centering the viewport 87 | """ 88 | user32 = ctypes.windll.user32 89 | screen_width = user32.GetSystemMetrics(0) 90 | screen_height = user32.GetSystemMetrics(1) 91 | 92 | x_pos = (screen_width - viewport_width) // 2 93 | y_pos = (screen_height - viewport_height) // 2 94 | 95 | return x_pos, y_pos 96 | 97 | def drag_viewport(self, sender, app_data, user_data): 98 | if not self._dragging_viewport or not is_left_mouse_button_down(): 99 | self._dragging_viewport = False 100 | self._drag_start_mouse_pos = None 101 | self._drag_start_viewport_pos = None 102 | return 103 | 104 | mouse_pos_global = get_mouse_screen_pos() 105 | if self._drag_start_mouse_pos is None or self._drag_start_viewport_pos is None: 106 | return 107 | 108 | dx = mouse_pos_global[0] - self._drag_start_mouse_pos[0] 109 | dy = mouse_pos_global[1] - self._drag_start_mouse_pos[1] 110 | new_x = self._drag_start_viewport_pos[0] + dx 111 | new_y = self._drag_start_viewport_pos[1] + dy 112 | 113 | #print(f"Mouse position: {mouse_pos_global}, Start position: {self._drag_start_mouse_pos}") 114 | #print(f"Dragging viewport by ({dx}, {dy}) to new position: ({new_x}, {new_y})") 115 | #current_viewport_pos = dpg.get_viewport_pos() 116 | #if (current_viewport_pos[0] != new_x) or (current_viewport_pos[1] != new_y): 117 | dpg.set_viewport_pos([new_x, new_y]) 118 | 119 | def on_mouse_release(self, sender, app_data, user_data): 120 | if self._dragging_viewport: 121 | self._dragging_viewport = False 122 | self._drag_start_mouse_pos = None 123 | self._drag_start_viewport_pos = None 124 | #print("Mouse released, stopping viewport dragging.") 125 | #else: 126 | #print("Mouse released normally") 127 | 128 | def on_mouse_click(self, sender, app_data, user_data): 129 | mouse_pos_global = get_mouse_screen_pos() 130 | mouse_pos_app = dpg.get_mouse_pos(local=False) 131 | mouse_y = mouse_pos_app[1] 132 | mouse_x = mouse_pos_app[0] 133 | if mouse_y < 40 and dpg.is_mouse_button_down(0) and mouse_x < (self.viewport_width - 75): 134 | self._dragging_viewport = True 135 | self._drag_start_mouse_pos = mouse_pos_global 136 | self._drag_start_viewport_pos = dpg.get_viewport_pos() 137 | print(f"Started dragging viewport at {mouse_pos_global}") 138 | else: 139 | self._dragging_viewport = False 140 | self._drag_start_mouse_pos = None 141 | self._drag_start_viewport_pos = None 142 | 143 | def _toggle_start_stop(self, icon, item): 144 | # Toggle running state and update menu label 145 | if self.cm and getattr(self.cm, "autopilot", False): 146 | return 147 | if self.start_stop_callback: 148 | self.start_stop_callback(None, None, self.cm) 149 | #self.running = not self.running 150 | # Update menu label 151 | self._update_menu() 152 | self.update_hover_text() 153 | 154 | def _update_menu(self): 155 | if self.icon: 156 | menu = Menu( 157 | MenuItem("Restore", self._restore_window), 158 | MenuItem( 159 | "Start" if not self.running else "Stop", 160 | self._toggle_start_stop, 161 | default=not self.cm.autopilot, 162 | enabled=not self.cm.autopilot # Disable if autopilot is on 163 | ), 164 | MenuItem( 165 | "Profiles", 166 | Menu(*self._profile_menu_items()), 167 | enabled=not self.running # Disable if running 168 | ), 169 | MenuItem( 170 | "Method", 171 | Menu(*list(self._method_menu_items())), 172 | enabled=not self.running # Disable if running 173 | ), 174 | MenuItem("Exit", self._exit_app) 175 | ) 176 | self.icon.menu = menu 177 | 178 | def set_running_state(self, running: bool): 179 | """Update running state and tray menu label.""" 180 | self.running = running 181 | self._update_menu() 182 | self._update_icon_image() 183 | self.update_hover_text() 184 | 185 | def _profile_menu_items(self): 186 | """Return a list of MenuItems for each profile (no checkmarks).""" 187 | profiles = [] 188 | if hasattr(self.cm, "profiles_config"): 189 | for profile in self.cm.profiles_config.sections(): 190 | def make_callback(profile_name): 191 | return lambda icon, item: ( 192 | dpg.set_value("profile_dropdown", profile_name), 193 | self._select_profile_from_tray(profile_name) 194 | ) 195 | profiles.append(MenuItem( 196 | profile, 197 | make_callback(profile) 198 | )) 199 | return profiles 200 | 201 | def _method_menu_items(self): 202 | """Return a list of MenuItems for each method (no checkmarks).""" 203 | methods = ["ratio", "step", "custom"] 204 | for m in methods: 205 | def make_callback(method_name): 206 | return lambda icon, item: ( 207 | dpg.set_value("input_capmethod", method_name), 208 | self._select_method_from_tray(method_name) 209 | ) 210 | yield MenuItem( 211 | m.capitalize(), 212 | make_callback(m) 213 | ) 214 | 215 | def _select_profile_from_tray(self, profile_name): 216 | """Callback for selecting a profile from the tray menu.""" 217 | if hasattr(self.cm, "load_profile_callback"): 218 | self.cm.load_profile_callback(None, profile_name, None) 219 | self._update_menu() 220 | self.update_hover_text() 221 | 222 | def _select_method_from_tray(self, method): 223 | """Callback for selecting a method from the tray menu.""" 224 | if hasattr(self.cm, "current_method_callback"): 225 | self.cm.current_method_callback(None, method, None) 226 | self._update_menu() 227 | self.update_hover_text() 228 | 229 | def _create_icon(self): 230 | self.update_hover_text() 231 | # Choose icon based on running state and autopilot 232 | if getattr(self.cm, "autopilot", False): 233 | if self.running: 234 | icon_file = self.icon_path.replace(".ico", "_dark_red.ico") 235 | else: 236 | icon_file = self.icon_path.replace(".ico", "_dark_green.ico") 237 | else: 238 | if self.running: 239 | icon_file = self.icon_path.replace(".ico", "_red.ico") 240 | else: 241 | icon_file = self.icon_path 242 | image = Image.open(icon_file) 243 | menu = Menu( 244 | MenuItem("Restore", self._restore_window), 245 | MenuItem( 246 | "Start" if not self.running else "Stop", 247 | self._toggle_start_stop, 248 | default=not self.cm.autopilot, 249 | enabled=not self.cm.autopilot # Disable if autopilot is on 250 | ), 251 | MenuItem( 252 | "Profiles", 253 | Menu(*self._profile_menu_items()), 254 | enabled=not self.running # Disable if running 255 | ), 256 | MenuItem( 257 | "Method", 258 | Menu(*list(self._method_menu_items())), 259 | enabled=not self.running # Disable if running 260 | ), 261 | MenuItem("Exit", self._exit_app) 262 | ) 263 | self.icon = Icon(self.app_name, image, self.hover_text, menu) 264 | 265 | def update_hover_text(self): 266 | """ 267 | Update the tray icon hover text with current profile, method, max FPS, and running status. 268 | Currently called within: 269 | 1) cm.current_method_callback(), since its called when loading profile and changing method 270 | 2) _toggle_start_stop 271 | 3) _create_icon 272 | """ 273 | if getattr(self.cm, "autopilot", False): 274 | status = "Autopilot Mode" 275 | else: 276 | status = "Click to Start" if not self.running else "Click to Stop" 277 | 278 | profile_name = dpg.get_value("profile_dropdown") 279 | method = dpg.get_value("input_capmethod") 280 | max_fps = max(self.fps_utils.current_stepped_limits()) if self.fps_utils else "?" 281 | 282 | self.hover_text = ( 283 | f"{self.app_name}\n" 284 | f"Profile: {profile_name}\n" 285 | f"Method: {method}\n" 286 | f"Max FPS: {max_fps}\n" 287 | f"{status}" 288 | ) 289 | if self.icon: 290 | self.icon.title = self.hover_text 291 | 292 | def _update_icon_image(self): 293 | """Update the tray icon image based on running state and autopilot.""" 294 | # Choose icon based on running state and autopilot 295 | if getattr(self.cm, "autopilot", False): 296 | if self.running: 297 | icon_file = self.icon_path.replace(".ico", "_dark_red.ico") 298 | else: 299 | icon_file = self.icon_path.replace(".ico", "_dark_green.ico") 300 | else: 301 | if self.running: 302 | icon_file = self.icon_path.replace(".ico", "_red.ico") 303 | else: 304 | icon_file = self.icon_path 305 | if self.icon: 306 | image = Image.open(icon_file) 307 | self.icon.icon = image 308 | 309 | def _restore_window(self, icon, item): 310 | # Called from tray thread, so use a thread to call the GUI callback 311 | threading.Thread(target=self.on_restore, daemon=True).start() 312 | self.is_tray_active = False 313 | show_to_taskbar() 314 | if self.icon: 315 | self.icon.stop() 316 | 317 | def _exit_app(self, icon, item): 318 | threading.Thread(target=self.on_exit, daemon=True).start() 319 | self.is_tray_active = False 320 | if self.icon: 321 | self.icon.stop() 322 | 323 | def show_tray(self): 324 | if self.is_tray_active: 325 | return 326 | self.is_tray_active = True 327 | self.tray_thread = threading.Thread(target=self._run_tray, daemon=True) 328 | self.tray_thread.start() 329 | 330 | def _run_tray(self): 331 | self._create_icon() 332 | self.icon.run() 333 | 334 | def minimize_to_tray(self): 335 | # Hide the main window and show tray icon 336 | hide_from_taskbar() 337 | self.show_tray() 338 | 339 | def restore_from_tray(self): 340 | # Show the main window and stop tray icon 341 | show_to_taskbar() 342 | self.is_tray_active = False 343 | if self.icon: 344 | self.icon.stop() 345 | 346 | def minimize_on_startup_if_needed(self, minimize: bool): 347 | """ 348 | Minimizes the app to tray if minimize is True. 349 | """ 350 | if minimize: 351 | self.minimize_to_tray() 352 | 353 | # Example usage in your main file: 354 | # import threading 355 | # from core.tray_functions import TrayManager, minimize_watcher 356 | # tray = TrayManager(...) 357 | # threading.Thread(target=minimize_watcher, args=(tray,), daemon=True).start() -------------------------------------------------------------------------------- /src/core/warning.py: -------------------------------------------------------------------------------- 1 | # warning.py 2 | 3 | def check_min_greater_than_max(dpg, cm): 4 | mincap = dpg.get_value("input_mincap") 5 | maxcap = dpg.get_value("input_maxcap") 6 | if mincap >= maxcap: 7 | return "[WARNING]: Minimum FPS limit should be less than maximum FPS limit." 8 | return None 9 | 10 | def check_rtss_running(rtss_manager): 11 | if not rtss_manager or not rtss_manager.is_rtss_running(): 12 | return "[WARNING]: RTSS is not running!" 13 | return None 14 | 15 | def check_min_greater_than_minvalidfps(dpg, cm, mincap): 16 | if mincap < cm.minvalidfps: 17 | return "[WARNING]: Minimum FPS limit should be > minimum valid FPS of {}. This setting can be changes in settings.ini".format(cm.minvalidfps) 18 | return None 19 | 20 | def check_non_global_profile_for_autopilot(cm): 21 | profiles = cm.profiles_config.sections() if hasattr(cm, "profiles_config") else [] 22 | if getattr(cm, "autopilot", False) and (len(profiles) <= 1 or profiles == ["Global"]): 23 | return "[WARNING]: Autopilot requires at least one non-Global profile to function properly." 24 | return None 25 | def get_active_warnings(dpg, cm, rtss_manager, mincap): 26 | warnings = [] 27 | # Add more checks as needed 28 | msg = check_min_greater_than_max(dpg, cm) 29 | if msg: 30 | warnings.append(msg) 31 | 32 | msg = check_rtss_running(rtss_manager) 33 | if msg: 34 | warnings.append(msg) 35 | 36 | msg = check_min_greater_than_minvalidfps(dpg, cm, mincap) 37 | if msg: 38 | warnings.append(msg) 39 | 40 | msg = check_non_global_profile_for_autopilot(cm) 41 | if msg: 42 | warnings.append(msg) 43 | # Add more warning checks here... 44 | return warnings -------------------------------------------------------------------------------- /src/metadata/version.txt: -------------------------------------------------------------------------------- 1 | # version.txt 2 | 3 | VSVersionInfo( 4 | ffi=FixedFileInfo( 5 | filevers=(4, 4, 2, 1), 6 | prodvers=(4, 4, 2, 1), 7 | mask=0x3f, 8 | flags=0x0, 9 | OS=0x4, 10 | fileType=0x1, 11 | subtype=0x0, 12 | date=(0, 0) 13 | ), 14 | kids=[ 15 | StringFileInfo( 16 | [ 17 | StringTable( 18 | '040904B0', 19 | [ 20 | StringStruct('CompanyName', 'SameSalamander5710'), 21 | StringStruct('FileDescription', 'DynamicFPSLimiter'), 22 | StringStruct('FileVersion', '4.4.2.1'), 23 | StringStruct('InternalName', 'DynamicFPSLimiter'), 24 | StringStruct('OriginalFilename', 'DynamicFPSLimiter.exe'), 25 | StringStruct('ProductName', 'DynamicFPSLimiter'), 26 | StringStruct('ProductVersion', '4.4.2.1') 27 | ] 28 | ) 29 | ] 30 | ), 31 | VarFileInfo([VarStruct('Translation', [1033, 1200])]) 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | dearpygui==2.0.0 2 | psutil==7.0.0 3 | pyinstaller==6.13.0 4 | pystray==0.19.5 --------------------------------------------------------------------------------