├── .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 |
31 |
32 |
33 |
34 |
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
--------------------------------------------------------------------------------