├── requirements.txt ├── PatchOpsIII.ico ├── packaging └── appimage │ ├── icons │ └── patchopsiii.png │ ├── PatchOpsIII.desktop │ └── AppRun ├── .gitignore ├── version.py ├── LICENSE ├── Release Notes ├── PatchOpsIII v1.0.1.md ├── PatchOpsIII v1.0.3-Beta.md ├── PatchOpsIII v1.0.md ├── template.md ├── PatchOpsIII v1.0.3.md ├── PatchOpsIII v1.0.4.md ├── PatchOpsIII v1.0.2.md └── PatchOpsIII v1.1.0.md ├── scripts └── build_appimage.sh ├── presets.json ├── wiki └── home.md ├── README.md ├── .github └── workflows │ ├── linux-build.yml │ ├── windows-build.yml │ ├── release-stable.yml │ └── release-beta.yml ├── dxvk_manager.py ├── utils.py ├── updater.py └── config.py /requirements.txt: -------------------------------------------------------------------------------- 1 | PySide6 2 | requests 3 | vdf 4 | zstandard 5 | -------------------------------------------------------------------------------- /PatchOpsIII.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boggedbrush/PatchOpsIII/HEAD/PatchOpsIII.ico -------------------------------------------------------------------------------- /packaging/appimage/icons/patchopsiii.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boggedbrush/PatchOpsIII/HEAD/packaging/appimage/icons/patchopsiii.png -------------------------------------------------------------------------------- /packaging/appimage/PatchOpsIII.desktop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env xdg-open 2 | [Desktop Entry] 3 | Type=Application 4 | Name=PatchOpsIII 5 | Exec=PatchOpsIII 6 | Icon=patchopsiii 7 | Categories=Game; 8 | -------------------------------------------------------------------------------- /packaging/appimage/AppRun: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | HERE="$(dirname "$(readlink -f "$0")")" 3 | export LD_LIBRARY_PATH="$HERE/usr/lib:$HERE/usr/lib64:$HERE/usr:${LD_LIBRARY_PATH:-}" 4 | exec "$HERE/usr/__main__" "$@" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python bytecode caches 2 | __pycache__/ 3 | 4 | # Mod Files: 5 | BO3 Mod Files/ 6 | 7 | # Virtual environment: 8 | .venv/ 9 | 10 | # Build files: 11 | *.bat 12 | build/ 13 | dist/ 14 | src/ 15 | 16 | # Log files: 17 | *.log 18 | 19 | # Executable files: 20 | *.exe 21 | 22 | # text files: 23 | *.txt 24 | !requirements.txt 25 | 26 | # vdf files: 27 | *.vdf 28 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | """Central location for the PatchOpsIII application version.""" 2 | from __future__ import annotations 3 | 4 | import os 5 | 6 | # Baked build version; update this when cutting a release so packaged binaries 7 | # report the correct version even when no environment variables are present. 8 | BUILT_APP_VERSION = "1.1.0" 9 | 10 | # Optional override for local testing without changing the baked version. 11 | APP_VERSION: str = os.environ.get("PATCHOPSIII_VERSION_OVERRIDE", BUILT_APP_VERSION) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 boggedbrush 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Release Notes/PatchOpsIII v1.0.1.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII v1.0.1 Release Notes 2 | 3 | The latest release of PatchOpsIII is here! Version 1.0.1 builds upon the initial pre-release with improvements aimed at reducing false positives from Windows Defender, as evidenced by the recent VirusTotal scan: 4 | [View VirusTotal Scan](https://www.virustotal.com/gui/file/dcb513ebe42d737b6647e92939d98cdaceed06031c363e19ca2bf674cb4e7874/detection) 5 | 6 | ## Key Improvements 7 | 8 | - **Improved Antivirus Compatibility:** 9 | This release incorporates changes to reduce Windows Defender and other antivirus false positives, ensuring a smoother installation experience. 10 | 11 | - **Download the Latest Release:** 12 | You can download PatchOpsIII v1.0.1 here: [Download PatchOpsIII.exe](https://github.com/boggedbrush/PatchOpsIII/releases/download/1.0.1/PatchOpsIII.exe) 13 | 14 | ## Acknowledgements 15 | 16 | This project is built upon the work of the following projects: 17 | 18 | - **t7patch:** [https://github.com/shiversoftdev/t7patch](https://github.com/shiversoftdev/t7patch) 19 | - **dxvk-gplasync:** [https://gitlab.com/Ph42oN/dxvk-gplasync](https://gitlab.com/Ph42oN/dxvk-gplasync) 20 | 21 | ## What's Next? 22 | 23 | - **Bug Fixes & Optimizations:** 24 | Ongoing improvements based on user feedback. 25 | 26 | - **Future Linux Support:** 27 | Linux compatibility is expected in future releases. 28 | 29 | - **Community Feedback:** 30 | As this is a pre-release, feedback is invaluable. Users are encouraged to report what works, suggest improvements, and propose new features. 31 | 32 | Thank you for supporting PatchOpsIII. Happy modding! -------------------------------------------------------------------------------- /Release Notes/PatchOpsIII v1.0.3-Beta.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII v1.0.3-Beta Release Notes 2 | 3 | The latest release of **PatchOpsIII** is here! Version **1.0.3-Beta** builds upon the previous release with new features, improvements, and changes. 4 | 5 | --- 6 | 7 | ## 🚀 **New Features:** 8 | - **Linux & Steam Deck Support:** 9 | - PatchOpsIII can now run on Linux with the new Linux executable! 10 | 11 | - **Quality of Life Improvements:** 12 | - **Launch Options:** Now works with the t7patch on Linux installations by automatically setting the necessary wine dll overrides. 13 | 14 | - **t7patch Management:** 15 | - Added Linux-friendly `t7patch` installation support. 16 | - Improved file path handling, downloading, and installing for non-Windows users. 17 | 18 | --- 19 | 20 | ## 🔄 **Changes:** 21 | - Improved UI scaling on Steam Deck. 22 | 23 | --- 24 | 25 | ## 🛠 **Fixes:** 26 | - **Linux Compatibility:** 27 | - Improved handling of launch parameters and t7patch installation for Steam Deck & Linux 28 | 29 | --- 30 | 31 | ## ⚠️ **Known Issues:** 32 | - **All-around Enhancement Mod:** 33 | - Current version doesn’t work with launch options (`Lite` version works fine). 34 | - **Launch Options Stability:** 35 | - May not work for all Linux distributions—still under testing. 36 | 37 | --- 38 | 39 | ## 📥 **Download the Latest Release:** 40 | [Download PatchOpsIII v1.0.3-Beta for Linux & Steam Deck](https://github.com/boggedbrush/PatchOpsIII/releases/download/1.0.3-Beta/PatchOpsIII) 41 | 42 | --- 43 | 44 | ## 🧑‍💻 **Acknowledgements:** 45 | PatchOpsIII is built upon the work of these amazing projects: 46 | - **t7patch:** [t7patch on GitHub](https://github.com/shiversoftdev/t7patch) 47 | - **dxvk-gplasync:** [dxvk-gplasync on GitLab](https://gitlab.com/Ph42oN/dxvk-gplasync) 48 | 49 | --- 50 | 51 | ## 🔮 **What’s Next?** 52 | - **Bug Fixes & Optimizations:** Continuing improvements based on user feedback. 53 | - **Community Feedback:** Your feedback is invaluable! Report issues, suggest improvements, and propose new features. 54 | 55 | --- 56 | 57 | Thank you for supporting **PatchOpsIII**. Happy modding! 🎮 -------------------------------------------------------------------------------- /Release Notes/PatchOpsIII v1.0.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII v1.0 Pre-Release Notes 2 | 3 | The first pre-release of PatchOpsIII is now available. This version integrates essential modding tools designed to enhance the **Call of Duty: Black Ops III** experience. Feedback is welcomed to further refine and improve the project. 4 | 5 | ## Key Features 6 | 7 | - **Unified Modding Tool:** 8 | Combines graphics tweaking, T7 Patch management, and DXVK-GPLAsync support in one user-friendly application. 9 | 10 | - **Graphics Settings Manager:** 11 | Allows adjustments to the FPS limiter, FOV, resolution, and more, with presets easily applied from the bundled JSON file. 12 | 13 | - **T7 Patch Management:** 14 | Enables customization of the gamertag with optional color codes and seamless updates to game settings. It manages Windows Defender exclusions and requires administrator rights when necessary. 15 | 16 | - **DXVK-GPLAsync Manager:** 17 | Provides options to install or uninstall DXVK-GPLAsync, reducing stuttering via asynchronous shader compilation. 18 | 19 | - **Logging:** 20 | Offers both in-app and file logging to track actions and troubleshoot issues efficiently. 21 | 22 | - **Executable Availability:** 23 | Features a dedicated **PatchOpsIII.exe** for an effortless Windows experience. 24 | [Download PatchOpsIII.exe](https://github.com/boggedbrush/PatchOpsIII/releases/download/1.0/PatchOpsIII.exe) 25 | Linux support is planned for the future. 26 | 27 | ## Acknowledgements 28 | 29 | This project is built upon the work of the following projects: 30 | 31 | - **t7patch:** [https://github.com/shiversoftdev/t7patch](https://github.com/shiversoftdev/t7patch) 32 | - **dxvk-gplasync:** [https://gitlab.com/Ph42oN/dxvk-gplasync](https://gitlab.com/Ph42oN/dxvk-gplasync) 33 | 34 | ## What's Next? 35 | 36 | - **Bug Fixes & Optimizations:** 37 | Ongoing improvements based on user feedback. 38 | 39 | - **Future Linux Support:** 40 | Linux compatibility is expected in future releases. 41 | 42 | - **Community Feedback:** 43 | As this is a pre-release, feedback is invaluable. Users are encouraged to report what works, suggest improvements, and propose new features. 44 | 45 | Feedback is appreciated as PatchOpsIII continues to evolve. Happy modding! -------------------------------------------------------------------------------- /Release Notes/template.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII {{VERSION}} Release Notes 2 | 3 | ## Overview 4 | {{OVERVIEW_SUMMARY}} 5 | 6 | --- 7 | 8 | ## 🚀 Major Highlights 9 | {{MAJOR_HIGHLIGHTS_LIST}} 10 | 11 | --- 12 | 13 | ## 📝 Detailed Changes 14 | {{DETAILED_CHANGES}} 15 | 16 | --- 17 | 18 | ## 🛠 Fixes 19 | 20 | ### Cross-Platform 21 | {{FIXES_CROSS_PLATFORM_LIST}} 22 | 23 | ### Windows 24 | {{FIXES_WINDOWS_LIST}} 25 | 26 | ### Linux and Steam Deck 27 | {{FIXES_LINUX_STEAM_LIST}} 28 | 29 | --- 30 | 31 | ## ⚠️ Known Issues 32 | 33 | - **All-around Enhancement Mod** 34 | - Impact: The current All-around Enhancement Mod does not work correctly when launch options are used; the Lite version remains compatible. 35 | - Workaround: Use the Lite version of the All-around Enhancement Mod when launch options are configured. 36 | - Status: Fix under investigation. 37 | 38 | - **Launch Options Stability on Linux and Steam Deck** 39 | - Impact: Launch options may not work consistently across all Linux distributions and Steam Deck setups. 40 | - Workaround: If issues occur, temporarily remove custom launch options and re-apply them incrementally. 41 | - Status: Behavior is being evaluated across additional distributions and Steam Deck configurations. 42 | 43 | --- 44 | 45 | ## 📥 Downloads & Verification 46 | 47 | - **Windows** 48 | - Download: [PatchOpsIII {{VERSION}} for Windows]({{WINDOWS_DOWNLOAD_URL}}) 49 | - SHA256: `{{WINDOWS_SHA256}}` 50 | - VirusTotal: [Latest Windows Scan]({{WINDOWS_VT_URL}}) 51 | 52 | - **Linux & Steam Deck** 53 | - Download: [PatchOpsIII {{VERSION}} for Linux & Steam Deck]({{LINUX_DOWNLOAD_URL}}) 54 | - SHA256: `{{LINUX_SHA256}}` 55 | - VirusTotal: [Latest Linux Scan]({{LINUX_VT_URL}}) 56 | 57 | --- 58 | 59 | ## 🧑‍💻 Acknowledgements 60 | PatchOpsIII is built upon the work of these amazing projects: 61 | - **t7patch:** [t7patch on GitHub](https://github.com/shiversoftdev/t7patch) 62 | - **dxvk-gplasync:** [dxvk-gplasync on GitLab](https://gitlab.com/Ph42oN/dxvk-gplasync) 63 | - **ValvePython/vdf:** [ValvePython/vdf on GitHub](https://github.com/ValvePython/vdf) 64 | 65 | --- 66 | 67 | ## 🔮 Upcoming Work 68 | - Bug fixes and performance optimizations based on user reports. 69 | - [BO3 Enhanced](https://github.com/shiversoftdev/BO3Enhanced) installation assistant tab for Windows users to automate installation from a Microsoft Store dump. 70 | - Additional improvements for Linux and Steam Deck launch option handling and broader distribution coverage. 71 | 72 | --- 73 | 74 | If you encounter issues or have suggestions, please open an issue on the repository or share feedback with the community so we can prioritize future improvements. 🎮 75 | -------------------------------------------------------------------------------- /Release Notes/PatchOpsIII v1.0.3.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII v1.0.3 Release Notes 2 | 3 | The latest release of **PatchOpsIII** is here! Version **1.0.3** builds upon the previous release with new features, improvements, and changes. 4 | 5 | 🔍 **VirusTotal Scan:** [Latest VirusTotal Scan](https://www.virustotal.com/gui/file/28be7fcdd9ae0302d8f6951cfb1a3942c71cfde84793ec334faf450e44b42d43?nocache=1) 6 | --- 7 | 8 | ## 🚀 **New Features:** 9 | - **Linux & Steam Deck Support:** 10 | - PatchOpsIII can now run on Linux with the new Linux executable! 11 | 12 | - **Quality of Life Improvements:** 13 | - **Launch Options:** Now works with the t7patch on Linux installations by automatically setting the necessary wine dll overrides. 14 | 15 | - **t7patch Management:** 16 | - Added Linux-friendly `t7patch` installation support. 17 | - Improved file path handling, downloading, and installing for non-Windows users. 18 | 19 | --- 20 | 21 | ## 🔄 **Changes:** 22 | - Improved UI scaling on Steam Deck. 23 | - When updating the t7patch, it will no longer reset your name to the default name `Unknown Soldier`. 24 | - Updated "Set VRAM target" in the Advanced tab to a percentage from 75-100% rather than 0.75-1.0 for better readability and understanding. 25 | 26 | --- 27 | 28 | ## 🛠 **Fixes:** 29 | - **Linux Compatibility:** 30 | - Improved handling of launch parameters and t7patch installation for Steam Deck & Linux 31 | - **t7patch Support:** 32 | - Updated to support the latest release of t7patch v2.0.4. You can learn more about the latest t7patch release [here](https://github.com/shiversoftdev/t7patch/releases/tag/Current) 33 | - **dxvk-gplasync Support:** 34 | - Updated to support the latest dxvk-gplasync v2.6-1 35 | 36 | --- 37 | 38 | ## ⚠️ **Known Issues:** 39 | - **All-around Enhancement Mod:** 40 | - Current version doesn’t work with launch options (`Lite` version works fine). 41 | - **Launch Options Stability:** 42 | - May not work for all Linux distributions—still under testing. 43 | - **Linux/Steam Deck App Icon:** 44 | - Linux & Steam Deck versions do not currently have an app icon. 45 | 46 | --- 47 | 48 | ## 📥 **Download the Latest Release:** 49 | [Download PatchOpsIII v1.0.3 for Windows](https://github.com/boggedbrush/PatchOpsIII/releases/download/1.0.3/PatchOpsIII.exe) 50 | [Download PatchOpsIII v1.0.3 for Linux & Steam Deck](https://github.com/boggedbrush/PatchOpsIII/releases/download/1.0.3/PatchOpsIII) 51 | 52 | --- 53 | 54 | ## 🧑‍💻 **Acknowledgements:** 55 | PatchOpsIII is built upon the work of these amazing projects: 56 | - **t7patch:** [t7patch on GitHub](https://github.com/shiversoftdev/t7patch) 57 | - **dxvk-gplasync:** [dxvk-gplasync on GitLab](https://gitlab.com/Ph42oN/dxvk-gplasync) 58 | 59 | --- 60 | 61 | ## 🔮 **What’s Next?** 62 | - **Bug Fixes & Optimizations:** Continuing improvements based on user feedback. 63 | - **[BO3 Enhanced](https://github.com/shiversoftdev/BO3Enhanced) Installation assistant tab for Windows users:** User's specify their Microsoft store dump of the game and PatchOpsIII will automate the installation for you! To learn more about BO3 Enhanced I recommend watching [this video](https://www.youtube.com/watch?v=rBZZTcSJ9_s) 64 | - **Community Feedback:** Your feedback is invaluable! Report issues, suggest improvements, and propose new features. 65 | 66 | --- 67 | 68 | Thank you for supporting **PatchOpsIII**. Happy modding! 🎮 -------------------------------------------------------------------------------- /Release Notes/PatchOpsIII v1.0.4.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII v1.0.4 Release Notes 2 | 3 | The latest release of **PatchOpsIII** is here! Version **1.0.4** brings significant improvements in user experience, stability, and functionality. 4 | 5 | 🔍 **VirusTotal Scans:** 6 | - Windows: [Latest Windows Scan]({{WINDOWS_VT_URL}}) 7 | - Linux: [Latest Linux Scan]({{LINUX_VT_URL}}) 8 | 9 | --- 10 | 11 | ## 🚀 New Features & Improvements 12 | - **Enhanced User Experience (UI Responsiveness):** 13 | - Applying Steam launch options and installing the T7 Patch now run asynchronously in separate threads, preventing the UI from freezing during these operations. 14 | - **Improved DXVK-GPLAsync Installation:** 15 | - The DXVK-GPLAsync installation is more robust, as it now recursively searches for the necessary DLL files within the extracted archive, making it less dependent on a specific folder structure. 16 | - Downloaded DXVK-GPLAsync archives will retain their original filenames for better clarity. 17 | - **Direct Game Launch:** 18 | - A new "Launch Game" button has been added to the main window, allowing users to directly launch Black Ops III via Steam from within the application. 19 | - **Refactored Codebase:** 20 | - Common utility functions related to Steam integration and launch options have been moved to a new `utils.py` module, improving code organization and maintainability. 21 | - **Linux Compatibility Improvements:** 22 | - A default game directory path for Linux installations has been added. 23 | - Improved handling of Steam processes on Linux with more robust process checks and timeouts. 24 | - **Robust Launch Options Management:** 25 | - The logic for setting Steam launch options has been refined to better handle existing `fs_game` parameters, ensuring correct application of new options without conflicts. 26 | - Timeouts have been added to Steam process management commands for more reliable execution. 27 | - **T7 Patch Installation Enhancements:** 28 | - The T7 Patch installation now includes a check for administrator privileges on Windows and will prompt for elevation if necessary, ensuring successful installation. 29 | 30 | --- 31 | 32 | ## 🛠 Fixes 33 | - Addressed various minor bugs and stability issues. 34 | - Fixed DXVK-GPLAsync auto-installation failures caused by new upstream archive formats by preferring extractable assets and supporting `.tar.zst` packages out of the box. 35 | 36 | --- 37 | 38 | ## ⚠️ Known Issues 39 | - **All-around Enhancement Mod:** 40 | - Current version doesn’t work with launch options (`Lite` version works fine). 41 | - **Launch Options Stability:** 42 | - May not work for all Linux distributions—still under testing. 43 | - **Linux/Steam Deck App Icon:** 44 | - Linux & Steam Deck versions do not currently have an app icon. 45 | 46 | --- 47 | 48 | ## 📥 Download the Latest Release 49 | - [Download PatchOpsIII v1.0.4 for Windows]({{WINDOWS_DOWNLOAD_URL}}) 50 | - [Download PatchOpsIII v1.0.4 for Linux & Steam Deck (PatchOpsIII.AppImage)]({{LINUX_DOWNLOAD_URL}}) 51 | 52 | --- 53 | 54 | ## 🏗 Build Metadata 55 | - Windows SHA256: `{{WINDOWS_SHA256}}` 56 | - Linux SHA256: `{{LINUX_SHA256}}` 57 | 58 | --- 59 | 60 | ## 🧑‍💻 Acknowledgements 61 | PatchOpsIII is built upon the work of these amazing projects: 62 | - **t7patch:** [t7patch on GitHub](https://github.com/shiversoftdev/t7patch) 63 | - **dxvk-gplasync:** [dxvk-gplasync on GitLab](https://gitlab.com/Ph42oN/dxvk-gplasync) 64 | - **ValvePython/vdf:** [ValvePython/vdf on GitHub](https://github.com/ValvePython/vdf) 65 | 66 | --- 67 | 68 | ## 🔮 What's Next? 69 | - **Bug Fixes & Optimizations:** Continuing improvements based on user feedback. 70 | - **[BO3 Enhanced](https://github.com/shiversoftdev/BO3Enhanced) Installation assistant tab for Windows users:** User's specify their Microsoft store dump of the game and PatchOpsIII will automate the installation for you! To learn more about BO3 Enhanced I recommend watching [this video](https://www.youtube.com/watch?v=rBZZTcSJ9_s) 71 | - **Community Feedback:** Your feedback is invaluable! Report issues, suggest improvements, and propose new features. 72 | 73 | --- 74 | 75 | Thank you for supporting **PatchOpsIII**. Happy modding! 🎮 76 | -------------------------------------------------------------------------------- /Release Notes/PatchOpsIII v1.0.2.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII v1.0.2 Release Notes 2 | 3 | The latest release of **PatchOpsIII** is here! Version **1.0.2** builds upon the previous release with new features, improvements, and changes. 4 | 5 | 🔍 **VirusTotal Scan:** [Latest VirusTotal Scan](https://www.virustotal.com/gui/file/622afd122d4f8e539c90efb33aae0ee2a4fda9c999795200b1ea7d9d2e8b55e2/summary) 6 | --- 7 | 8 | 💖 **Community Appreciation:** 9 | I want to take a moment to thank everyone for the kind words and support so far. Your feedback motivates me to continue improving PatchOpsIII with features that you want. Your contributions are invaluable, and I look forward to growing and evolving the project together! 10 | 11 | --- 12 | 13 | ## 🚀 **New Features:** 14 | - **GUI Enhancements:** 15 | - Added tab functionality for better organization (`Mods`, `Graphics`, & `Advanced`). 16 | - Introduced light mode support. 17 | 18 | - **Quality of Life Improvements:** 19 | - New `Quality of Life` section. 20 | - **Skip All Intro Videos:** Easily bypass intro videos. 21 | - **Launch Options:** Added support for `Play Offline`, `All-around Enhancement Lite`, and `Ultimate Experience Mod`. 22 | - All-around Enhancement Lite: [More Info](https://steamcommunity.com/sharedfiles/filedetails/?id=2994481309) 23 | - With the `All-around Enhancement Lite` launch option, users can now adjust their fov-scale in-game, ensuring the same FOV even when aiming down sight. [Support the Mod!](https://www.paypal.com/paypalme/mhd4bf1) 24 | - Ultimate Experience Mod: [More Info](https://steamcommunity.com/sharedfiles/filedetails/?id=2942053577) 25 | - Highly recommended! Thank you sphnxmods for the kind words & the UEM Discord support. [Support the Mod!](https://ultimate-experience-mod.com/support-the-mod) 26 | - **Clickable Help (`?`):** Access more info and download links for the mentioned Steam Workshop mods. 27 | 28 | - **t7patch Management:** 29 | - **LPC Installation:** Helps resolve `A.B.C` error. 30 | - **Uninstall Button:** Easily remove `t7patch` to fix issues. 31 | - **Network Password Field:** Added for network configurations. 32 | - **Friends Only Mode:** Toggle switch for `t7patch` sessions. 33 | 34 | - **Installation Improvements:** 35 | - PatchOpsIII can detect if it's in the same directory as `Black Ops III` (supports non-`C:` drive installs). 36 | 37 | --- 38 | 39 | ## 🔄 **Changes:** 40 | - **"Skip Intro Video"** moved to the `Quality of Life` section. 41 | - Renamed `"Reduce Stutter"` to `"Use Latest d3dcompiler"` for better clarity. 42 | - Added short descriptions to options in the `Advanced` tab. 43 | 44 | --- 45 | 46 | ## 🛠 **Fixes:** 47 | - **Antivirus Compatibility:** 48 | - Reduced Windows Defender and other antivirus false positives. 49 | 50 | - **Refresh Rate Handling:** 51 | - Added support for arbitrary refresh rates (e.g., `59.999Hz`, `144.001Hz`, etc.). 52 | 53 | - **Mod File Download Issue:** 54 | - Resolved the issue with `Black Ops III` mod files downloading to the `temp` directory. 55 | 56 | - **GPLAsync Enabling:** 57 | - Fixed a missing `dxvk.conf` file preventing `GPLAsync` from enabling. 58 | 59 | --- 60 | 61 | ## ⚠️ **Known Issues:** 62 | - **All-around Enhancement Mod:** 63 | - Current version doesn't work with launch options (`Lite` version works fine). 64 | - **Launch Options Stability:** 65 | - May not work for all users—still under testing. 66 | 67 | --- 68 | 69 | ## 📥 **Download the Latest Release:** 70 | [Download PatchOpsIII v1.0.2](https://github.com/boggedbrush/PatchOpsIII/releases/download/1.0.2/PatchOpsIII.exe) 71 | 72 | --- 73 | 74 | ## 🧑‍💻 **Acknowledgements:** 75 | PatchOpsIII is built upon the work of these amazing projects: 76 | - **t7patch:** [t7patch on GitHub](https://github.com/shiversoftdev/t7patch) 77 | - **dxvk-gplasync:** [dxvk-gplasync on GitLab](https://gitlab.com/Ph42oN/dxvk-gplasync) 78 | 79 | --- 80 | 81 | ## 🔮 **What's Next?** 82 | - **Bug Fixes & Optimizations:** Continuing improvements based on user feedback. 83 | - **Future Linux Support:** Expected in upcoming releases. 84 | - **Community Feedback:** Your feedback is invaluable! Report issues, suggest improvements, and propose new features. 85 | 86 | --- 87 | 88 | Note: The [Wiki page](https://github.com/boggedbrush/PatchOpsIII/wiki) and the [Main page](https://github.com/boggedbrush/PatchOpsIII) will be updated with the new changes soon. 89 | Thank you for supporting **PatchOpsIII**. Happy modding! 🎮 -------------------------------------------------------------------------------- /scripts/build_appimage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | cleanup() { 5 | rm -rf linuxdeploy plugin-qt plugin-python plugin-appimage 6 | } 7 | trap cleanup EXIT 8 | 9 | # 1) Nuitka standalone build 10 | python -m pip install -U nuitka pyside6 11 | 12 | NUITKA_ARGS=( 13 | "main.py" 14 | "--standalone" 15 | "--follow-imports" 16 | "--enable-plugin=pyside6" 17 | "--include-qt-plugins=sensible,platforms,platformthemes,iconengines,imageformats,tls" 18 | "--output-dir=build/nuitka" 19 | "--output-filename=__main__" 20 | ) 21 | 22 | if [ -d "assets" ]; then 23 | NUITKA_ARGS+=("--include-data-files=assets/**=assets/") 24 | fi 25 | if [ -f "presets.json" ]; then 26 | NUITKA_ARGS+=("--include-data-files=presets.json=presets.json") 27 | fi 28 | 29 | python -m nuitka "${NUITKA_ARGS[@]}" 30 | 31 | # 2) Construct AppDir 32 | APPDIR=build/AppDir 33 | rm -rf "$APPDIR" 34 | mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/applications" "$APPDIR/usr/share/icons/hicolor/256x256/apps" 35 | 36 | install -m755 packaging/appimage/AppRun "$APPDIR/AppRun" 37 | install -m644 packaging/appimage/PatchOpsIII.desktop "$APPDIR/PatchOpsIII.desktop" 38 | install -m644 packaging/appimage/PatchOpsIII.desktop "$APPDIR/usr/share/applications/PatchOpsIII.desktop" 39 | install -m644 packaging/appimage/icons/patchopsiii.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/patchopsiii.png" 40 | install -m644 packaging/appimage/icons/patchopsiii.png "$APPDIR/patchopsiii.png" 41 | 42 | # Copy Nuitka output 43 | cp -a build/nuitka/main.dist/* "$APPDIR/usr/" 44 | 45 | # Prepare output directory 46 | OUTPUT_DIR=dist/appimage 47 | mkdir -p "$OUTPUT_DIR" 48 | rm -f "$OUTPUT_DIR/PatchOpsIII.AppImage" "$OUTPUT_DIR/PatchOpsIII.AppImage.zsync" 49 | rm -f PatchOpsIII.AppImage PatchOpsIII.AppImage.zsync 50 | # Remove previous raw artifacts to avoid stale matches 51 | rm -f PatchOpsIII-*.AppImage PatchOpsIII-*.AppImage.zsync 52 | 53 | # 3) Fetch linuxdeploy toolchain 54 | TOOLS_DIR=build/linuxdeploy-tools 55 | mkdir -p "$TOOLS_DIR" 56 | 57 | wget_if_missing() { 58 | local url="$1" 59 | local dest="$2" 60 | if [ ! -f "$dest" ]; then 61 | wget -q "$url" -O "$dest" 62 | fi 63 | } 64 | 65 | wget_if_missing "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-x86_64.AppImage" 66 | wget_if_missing "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-qt-x86_64.AppImage" 67 | wget_if_missing "https://github.com/niess/linuxdeploy-plugin-python/releases/download/continuous/linuxdeploy-plugin-python-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-python-x86_64.AppImage" 68 | wget_if_missing "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-appimage-x86_64.AppImage" 69 | chmod +x "$TOOLS_DIR"/linuxdeploy-*.AppImage 70 | export PATH="$TOOLS_DIR:$PATH" 71 | export APPIMAGE_EXTRACT_AND_RUN=1 72 | 73 | # 4) Embed update metadata + build 74 | export UPDATE_INFORMATION="gh-releases-zsync|boggedbrush|PatchOpsIII|latest|PatchOpsIII.AppImage.zsync" 75 | export LDAI_UPDATE_INFORMATION="$UPDATE_INFORMATION" 76 | 77 | LINUXDEPLOY_CMD=("$TOOLS_DIR/linuxdeploy-x86_64.AppImage" --appdir "$APPDIR") 78 | 79 | if command -v qmake >/dev/null 2>&1; then 80 | LINUXDEPLOY_CMD+=(--plugin qt) 81 | elif command -v qmake6 >/dev/null 2>&1; then 82 | export QMAKE="$(command -v qmake6)" 83 | LINUXDEPLOY_CMD+=(--plugin qt) 84 | else 85 | echo "Warning: qmake not found; skipping linuxdeploy qt plugin." >&2 86 | fi 87 | 88 | LINUXDEPLOY_CMD+=(--output appimage) 89 | 90 | "${LINUXDEPLOY_CMD[@]}" 91 | 92 | RAW_APPIMAGE=$(ls -1t PatchOpsIII-*.AppImage 2>/dev/null | head -n1 || true) 93 | if [ -z "$RAW_APPIMAGE" ] || [ ! -f "$RAW_APPIMAGE" ]; then 94 | echo "Failed to locate linuxdeploy output AppImage." >&2 95 | exit 1 96 | fi 97 | mv "$RAW_APPIMAGE" "$OUTPUT_DIR/PatchOpsIII.AppImage" 98 | chmod +x "$OUTPUT_DIR/PatchOpsIII.AppImage" 99 | 100 | RAW_ZSYNC="${RAW_APPIMAGE}.zsync" 101 | if [ -f "$RAW_ZSYNC" ]; then 102 | mv "$RAW_ZSYNC" "$OUTPUT_DIR/PatchOpsIII.AppImage.zsync" 103 | fi 104 | 105 | echo "AppImage created at $OUTPUT_DIR/PatchOpsIII.AppImage" 106 | if [ -f "$OUTPUT_DIR/PatchOpsIII.AppImage.zsync" ]; then 107 | echo "zsync created at $OUTPUT_DIR/PatchOpsIII.AppImage.zsync" 108 | fi 109 | 110 | rm -rf linuxdeploy plugin-qt plugin-python plugin-appimage 111 | -------------------------------------------------------------------------------- /presets.json: -------------------------------------------------------------------------------- 1 | { 2 | "Smooth Preset": { 3 | "SmoothFramerate": ["1", "Enable smoothing at the cost of some performance"], 4 | "CorpseCount": ["16", "Maximum number of corpses is 32, 16 is selected for a balanced approach"], 5 | "MaxFrameLatency": ["4", "Driver can queue up to 4 frames"], 6 | "SerializeRender": ["0", "0 is the default for the best performance, 1 and 2 improve latency but require a powerful CPU."], 7 | "VideoMemory": ["1", "Target all of the available VRAM to reduce stuttering"], 8 | "RestrictGraphicsOptions": ["0", "Advanced graphics options unlocked"], 9 | "Vsync": ["1", "Vertical sync enabled"], 10 | "BackbufferCount": ["3", "Triple buffering enabled"], 11 | "MeshQuality": ["0", "Highest quality meshes"], 12 | "TextureFilter": ["2", "High anisotropic filtering"], 13 | "TextureQuality": ["1", "High texture quality (balanced)"], 14 | "TextureQualityFX": ["1", "Improved effect quality"], 15 | "TextureQualityProbes": ["0", "Default reflection quality"], 16 | "TextureQualityBakedSunShadows": ["1", "Slightly reduced baked sun shadow quality"], 17 | "TextureLowDetailResident": ["0", "Lowest detail resident streaming disabled"], 18 | "DisableDynamicLightShadows": ["1", "Dynamic light shadows disabled"], 19 | "DisableDynamicSunShadows": ["1", "Dynamic sun shadows disabled"], 20 | "SpotShadowTextureSize": ["1024", "Spot light shadow resolution set to 1024"], 21 | "OmniShadowTextureSize": ["256", "Omni (point) light shadow resolution set to 256"], 22 | "ShadowFiltering": ["1", "Multi-sampled soft shadows enabled"], 23 | "ActorShadows": ["0", "Actor shadows disabled"], 24 | "VolumetricLightingEnabled": ["0", "Volumetric lighting disabled"], 25 | "VolumetricLightingMaxSunSamples": ["8", "Max sun samples for volumetric lighting"], 26 | "VolumetricLightingMaxLightSamples": ["40", "Max light samples for volumetric lighting"], 27 | "VolumetricLightingSkipSunSamples": ["1", "Skip alternate sun samples"], 28 | "VolumetricLightingSkipLightSamples": ["1", "Skip alternate light samples"], 29 | "OIT": ["1", "Order-independent transparency enabled"], 30 | "OITLayers": ["8", "8 transparency layers set"], 31 | "SSAOTechnique": ["GTAO Low Quality", "Ambient occlusion set to GTAO Low Quality"], 32 | "AATechnique": ["FXAA", "Anti-aliasing set to FXAA"], 33 | "MotionBlur": ["Off", "Motion blur disabled"], 34 | "MotionBlurQuality": ["Medium", "Medium quality motion blur (if enabled)"], 35 | "SubsurfaceScattering": ["0", "Subsurface scattering disabled"], 36 | "StreamMinResident": ["0", "No reduction in minimum asset residency in VRAM"] 37 | }, 38 | "Low-Latency Preset": { 39 | "SmoothFramerate": ["0", "Disable smoothing to minimize frame delay"], 40 | "CorpseCount": ["16", "Maximum number of corpses is 32, 16 is selected for a balanced approach"], 41 | "MaxFrameLatency": ["1", "Driver can only queue up to 1 frame"], 42 | "SerializeRender": ["2", "0 is the default for the best performance, 1 and 2 improve latency but require a powerful CPU."], 43 | "VideoMemory": ["0.75", "Target 75% the available VRAM to increase performance"], 44 | "RestrictGraphicsOptions": ["0", "Advanced graphics options unlocked"], 45 | "Vsync": ["0", "Vertical sync disabled for lower latency"], 46 | "BackbufferCount": ["2", "Reset Vsync to double buffer"], 47 | "MeshQuality": ["0", "Highest quality meshes"], 48 | "TextureFilter": ["2", "High anisotropic filtering"], 49 | "TextureQuality": ["1", "High texture quality (balanced)"], 50 | "TextureQualityFX": ["1", "Improved effect quality"], 51 | "TextureQualityProbes": ["0", "Default reflection quality"], 52 | "TextureQualityBakedSunShadows": ["1", "Slightly reduced baked sun shadow quality"], 53 | "TextureLowDetailResident": ["0", "Lowest detail resident streaming disabled"], 54 | "DisableDynamicLightShadows": ["1", "Dynamic light shadows disabled"], 55 | "DisableDynamicSunShadows": ["1", "Dynamic sun shadows disabled"], 56 | "SpotShadowTextureSize": ["1024", "Spot light shadow resolution set to 1024"], 57 | "OmniShadowTextureSize": ["256", "Omni (point) light shadow resolution set to 256"], 58 | "ShadowFiltering": ["1", "Multi-sampled soft shadows enabled"], 59 | "ActorShadows": ["0", "Actor shadows disabled"], 60 | "VolumetricLightingEnabled": ["0", "Volumetric lighting disabled"], 61 | "VolumetricLightingMaxSunSamples": ["8", "Max sun samples for volumetric lighting"], 62 | "VolumetricLightingMaxLightSamples": ["40", "Max light samples for volumetric lighting"], 63 | "VolumetricLightingSkipSunSamples": ["1", "Skip alternate sun samples"], 64 | "VolumetricLightingSkipLightSamples": ["1", "Skip alternate light samples"], 65 | "OIT": ["1", "Order-independent transparency enabled"], 66 | "OITLayers": ["8", "8 transparency layers set"], 67 | "SSAOTechnique": ["GTAO Low Quality", "Ambient occlusion set to GTAO Low Quality"], 68 | "AATechnique": ["FXAA", "Anti-aliasing set to FXAA"], 69 | "MotionBlur": ["Off", "Motion blur disabled"], 70 | "MotionBlurQuality": ["Medium", "Medium quality motion blur (if enabled)"], 71 | "SubsurfaceScattering": ["0", "Subsurface scattering disabled"], 72 | "StreamMinResident": ["1", "Reduce the minimum asset residency in VRAM"] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /wiki/home.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII Wiki 2 | 3 | ## Overview 4 | PatchOpsIII is a Python-based application developed by [boggedbrush](https://github.com/boggedbrush/PatchOpsIII). The project is designed to streamline and optimize operations through a robust and versatile framework. The application features a tabbed interface (`Mods`, `Graphics`, & `Advanced`) with both dark and light mode support. The application is packaged using Nuitka to support both Linux and Windows environments. 5 | 6 | ![Program Screenshot](https://github.com/user-attachments/assets/a79e7273-4274-4a43-8d4d-e81a12cbd1ff) 7 | 8 | --- 9 | 10 | ## Features 11 | 12 | ### 1. Mods Tab 13 | 14 | #### 1.1 Game Directory 15 | Allows users to specify their game directory. The default behavior is to search for: 16 | 17 | C:\Program Files (x86)\Steam\steamapps\common\Call of Duty Black Ops III 18 | 19 | If the directory isn't found, users can manually select the game's installation folder using the "Browse" button at the top right. 20 | 21 | #### 1.2 T7Patch Management 22 | The **T7 Patch** is a community-developed modification for *Call of Duty: Black Ops III* that addresses various security vulnerabilities and performance issues within the game. 23 | 24 | **Key Benefits:** 25 | - Fixes remote code execution (RCE) vulnerabilities 26 | - Prevents potential exploits 27 | - Resolves FPS-related problems 28 | 29 | PatchOpsIII enables users to: 30 | - Install and update the T7 Patch 31 | - Update their gamertag 32 | - Set their gamertag color 33 | - Configure network password 34 | - Toggle Friends Only Mode 35 | - Install LPC to resolve A.B.C errors 36 | - Uninstall T7Patch when needed 37 | 38 | This management only needs to run once and does not require `t7patch.exe` to remain open in the background. Implementing the T7 Patch is crucial for maintaining game security and performance, as it safeguards against known exploits and enhances overall stability. 39 | 40 | You can learn more about the T7 Patch [here](https://github.com/shiversoftdev/t7patch). 41 | 42 | #### 1.3 DXVK-GPLAsync Management 43 | **Shader compilation stuttering** is a common issue in PC gaming, causing noticeable delays when new shaders are compiled during gameplay. DXVK-GPLAsync offers a solution by converting **DirectX** calls to **Vulkan** with asynchronous shader compilation, reducing stutters for consistently smooth frametimes. 44 | 45 | **Feature Highlights:** 46 | - Install/uninstall `dxvk-gplasync` 47 | - Minimize in-game stuttering caused by real-time shader compilation for smoother framerates 48 | - Enhance performance and reduce latency, especially for stutter-prone games 49 | 50 | Learn more about [DXVK](https://www.pcgamingwiki.com/wiki/DXVK), [DXVK-GPLAsync](https://gitlab.com/Ph42oN/dxvk-gplasync), and [shader stutter](https://youtu.be/f7yml1y3fDE?si=NpwybZNqIRVhxmL7). 51 | 52 | #### 1.4 Quality of Life Features 53 | - **Skip All Intro Videos:** Bypass all game intro videos 54 | - **Launch Options:** Support for various mod configurations: 55 | - Play Offline 56 | - [All-around Enhancement Lite](https://steamcommunity.com/sharedfiles/filedetails/?id=2994481309) 57 | - [Ultimate Experience Mod](https://steamcommunity.com/sharedfiles/filedetails/?id=2942053577) 58 | - **Clickable Help:** Access detailed information and download links for Steam Workshop mods 59 | 60 | ### 2. Graphics Tab 61 | 62 | #### 2.1 Graphic Presets 63 | Allows users to apply graphics presets from pre-configured JSON files for quick and easy configuration. 64 | 65 | #### 2.2 Basic Settings 66 | - **Skip Intro Video:** Renames `BO3_Global_Logo_LogoSequence.mkv` to `.bak`, skipping the intro cutscene. [Skip intro videos](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#Skip_intro_videos) 67 | - **FPS Limiter:** Set the FPS limiter from **0-1000** (previously **24-1000**). Setting to **0** can improve loading speed. [Increased loading speed](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#Increased_loading_speed_levels) 68 | - **Convenience Settings:** Adjust: 69 | - Field of View (FOV) 70 | - Display Mode 71 | - Resolution 72 | - Refresh Rate 73 | - Render Resolution % 74 | - Enable V-Sync 75 | - Show FPS Counter 76 | 77 | ### 3. Advanced Tab 78 | 79 | #### 3.1 Advanced Settings 80 | - **Smooth Framerate:** Changes `SmoothFramerate` from **0** to **1** in `config.ini`. [Frame rate isn't smooth](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#Frame_rate_isn.27t_smooth) 81 | - **Use Full VRAM:** Sets `VideoMemory` to **1** and `StreamMinResident` to **0** in `config.ini`. [Use full VRAM](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#Game_does_not_take_advantage_of_the_entire_VRAM_amount_available) 82 | - **Lower Latency:** Modifies `MaxFrameLatency` in `config.ini` to allow between **0 (System Level)** and **4** queued frames. [Improve responsiveness](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#Game_isn.27t_responsive_enough) 83 | - **Reduce CPU Usage:** Toggles `SerializeRender` from **0** to **2**, recommended for older/weaker CPUs. [High CPU usage](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#CPU_usage_sometimes_goes_too_high_on_some_configurations) 84 | - **Reduce Stuttering:** Renames `d3dcompiler_46.dll` to `.bak` to enforce the latest DirectX11 version. [Stuttering issues](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#Stuttering) 85 | - **Unlock All Graphics Options:** Sets `RestrictGraphicsOptions` from **1** to **0** in `config.ini`. [Unlock settings](https://www.pcgamingwiki.com/wiki/Call_of_Duty:_Black_Ops_III#Make_all_settings_available) 86 | - **Lock `config.ini` (read-only):** Prevents unintended changes by setting `config.ini` to **read-only**. 87 | 88 | ### 4. Terminal/Log Window 89 | Displays logs in a terminal view, providing transparency about what operations succeeded or failed. 90 | 91 | - **Log Creation:** On startup, a `PatchOpsIII.log` file is generated with full session logs. 92 | - **Troubleshooting:** Users can easily identify issues through detailed log messages. 93 | 94 | ### Known Issues 95 | - [All-around Enhancement Mod](https://steamcommunity.com/sharedfiles/filedetails/?id=2631943123) (full version) currently crashes before launch, so it is not provided as a PatchOpsIII launch option 96 | - Launch options stability may vary between users 97 | - Some features are still under testing -------------------------------------------------------------------------------- /Release Notes/PatchOpsIII v1.1.0.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII v1.1.0 Release Notes 2 | 3 | ## Overview 4 | PatchOpsIII v1.1.0 is the first stable release following the v1.0.4 beta, focused on a built-in cross-platform updater, hardened Linux/AppImage packaging, and safer configuration handling. This release is recommended for all users on v1.0.x, especially those on Linux or Steam Deck. The primary highlight is the integrated auto-updater for Windows and Linux with more reliable release detection. 🎮 5 | 6 | --- 7 | 8 | ## 🚀 Major Highlights 9 | - Auto-updater for Windows and Linux. 10 | - AppImage-based packaging for Linux and Steam Deck. 11 | - Improved Steam, DXVK, and launch option handling. 12 | - UI/UX refinements and safer configuration behavior. 13 | 14 | --- 15 | 16 | ## 📝 Detailed Changes 17 | 18 | ### Updater 19 | - Added a unified update button that runs OS-specific checks for both Windows and Linux (#23). 20 | - Enabled automatic update checks on Windows at startup with links to the latest GitHub release when a newer build is available (#23). 21 | - Integrated Linux update flow with Gear Lever (an AppImage management tool) to apply AppImage updates when a newer AppImage is detected (#23). 22 | 23 | ### Packaging (Linux) 24 | - Switched Linux builds to AppDir-based AppImages with proper icons, bundled presets, persistent logs/backups, and update-friendly metadata (#18). 25 | - Limited Linux release asset selection to real `.AppImage` and `.zsync` artifacts to improve both manual and automatic update detection (#18). 26 | 27 | ### Game Management Improvements 28 | - Centralized version data in `version.py` so packaged builds, tags, and updater prompts stay consistent across platforms (#23, #5, #7). 29 | - Grouped game directory tools with a dedicated update button and cached platform detection to reduce redundant checks (#23). 30 | - Improved game directory detection to validate and remember user selections across runs, including Nuitka onefile builds (#5, #7). 31 | 32 | ### Linux, Steam Deck, and DXVK Enhancements 33 | - Improved DXVK-GPLAsync installs to remain robust against upstream archive changes (including `.tar.zst`) and to preserve downloaded filenames for clarity (#9). 34 | - Adjusted default Linux paths and Steam process handling to better accommodate different distributions and Steam Deck setups (#9). 35 | - Ensured launch options are preserved on Linux T7 Patch installs, keeping existing `fs_game` and mod settings intact (#9). 36 | 37 | ### UI/UX and Configuration 38 | - Promoted v1.0.4 beta quality-of-life improvements to stable: background threading for applying Steam launch options and installing the T7 Patch to keep the UI responsive. 39 | - Refined direct “Launch Game” behavior via Steam so the main window Launch Game button works reliably. 40 | - Added admin checks for T7 Patch installs on Windows to reduce failures due to missing elevation. 41 | - Updated FOV slider behavior and graphics presets to avoid overwriting existing user settings unexpectedly. 42 | - Guarded configuration writes so they only occur once a valid install directory has been detected, reducing the risk of invalid or partial configuration files (#5, #7). 43 | - Improved log copy resilience and enabled automatic log cleanup every three launches to keep log files small and manageable. 44 | 45 | ### Documentation and Build System 46 | - Refreshed the README with updated badges and clearer usage and setup guidance (#20). 47 | - Updated release workflows to improve tag selection and note publishing across both beta and stable channels (#20, #16, #12, #11, #10). 48 | 49 | --- 50 | 51 | ## 🛠 Fixes 52 | 53 | ### Cross-Platform 54 | - **Resolved false positives when discovering the install directory in packaged builds and ensured configuration writes only occur once a valid path exists (#5, #7).** 55 | - Reduced log noise by tightening update-related logging and making log copy operations more resilient. 56 | - Adjusted FOV slider and graphics presets to prevent them from overwriting existing user-defined settings. 57 | 58 | ### Windows 59 | - **Fixed Windows auto-update handling to prevent duplicate update checks and ensure staged updates are applied safely (#23).** 60 | - **Corrected Windows elevation detection for T7 Patch installs to reduce failed installations caused by insufficient permissions.** 61 | - Reduced Steam shutdown-related noise in logs on Windows when starting or closing the game. 62 | 63 | ### Linux and Steam Deck 64 | - **Fixed DXVK auto-install failures by preferring extractable assets and surfacing lookup errors more clearly (#9).** 65 | - Ensured manual Linux update checks consistently surface the latest release via the GitHub API (#23). 66 | - Preserved existing `fs_game` and mod settings during Linux T7 Patch installs to avoid unintended changes to launch options. 67 | 68 | --- 69 | 70 | ## ⚠️ Known Issues 71 | 72 | - **All-around Enhancement Mod** 73 | - Impact: The current All-around Enhancement Mod does not work correctly when launch options are used; the Lite version remains compatible. 74 | - Workaround: Use the Lite version of the All-around Enhancement Mod when launch options are configured. 75 | - Status: Fix under investigation. 76 | 77 | - **Launch Options Stability on Linux and Steam Deck** 78 | - Impact: Launch options may not work consistently across all Linux distributions and Steam Deck setups. 79 | - Workaround: If issues occur, temporarily remove custom launch options and re-apply them incrementally. 80 | - Status: Behavior is being evaluated across additional distributions and Steam Deck configurations. 81 | 82 | --- 83 | 84 | ## 📥 Downloads & Verification 85 | 86 | - **Windows** 87 | - Download: [PatchOpsIII v1.1.0 for Windows]({{WINDOWS_DOWNLOAD_URL}}) 88 | - SHA256: `{{WINDOWS_SHA256}}` 89 | - VirusTotal: [Latest Windows Scan]({{WINDOWS_VT_URL}}) 90 | 91 | - **Linux & Steam Deck** 92 | - Download: [PatchOpsIII v1.1.0 for Linux & Steam Deck]({{LINUX_DOWNLOAD_URL}}) 93 | - SHA256: `{{LINUX_SHA256}}` 94 | - VirusTotal: [Latest Linux Scan]({{LINUX_VT_URL}}) 95 | 96 | --- 97 | 98 | ## 🧑‍💻 Acknowledgements 99 | PatchOpsIII builds on the work of the following projects: 100 | - **t7patch:** [t7patch on GitHub](https://github.com/shiversoftdev/t7patch) 101 | - **dxvk-gplasync:** [dxvk-gplasync on GitLab](https://gitlab.com/Ph42oN/dxvk-gplasync) 102 | - **ValvePython/vdf:** [ValvePython/vdf on GitHub](https://github.com/ValvePython/vdf) 103 | 104 | --- 105 | 106 | ## 🔮 Upcoming Work 107 | - Bug fixes and performance optimizations based on user reports. 108 | - [BO3 Enhanced](https://github.com/shiversoftdev/BO3Enhanced) installation assistant tab for Windows users to automate installation from a Microsoft Store dump. 109 | - Additional improvements for Linux and Steam Deck launch option handling and broader distribution coverage. 110 | 111 | --- 112 | 113 | If you encounter issues or have suggestions, please open an issue on the repository or share feedback with the community so we can prioritize future improvements. 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PatchOpsIII 2 | 3 | [![Latest Release](https://img.shields.io/github/v/release/boggedbrush/PatchOpsIII?style=for-the-badge&color=0a84ff)](https://github.com/boggedbrush/PatchOpsIII/releases) 4 | [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/boggedbrush/PatchOpsIII/total?style=for-the-badge&color=34c759)](https://github.com/boggedbrush/PatchOpsIII/releases) 5 | [![GitHub Stars](https://img.shields.io/github/stars/boggedbrush/PatchOpsIII?style=for-the-badge&color=ff9f0a)](https://github.com/boggedbrush/PatchOpsIII/stargazers) 6 | [![GitHub Issues](https://img.shields.io/github/issues/boggedbrush/PatchOpsIII?style=for-the-badge&color=ff453a)](https://github.com/boggedbrush/PatchOpsIII/issues) 7 | [![License](https://img.shields.io/github/license/boggedbrush/PatchOpsIII?style=for-the-badge&color=5e5ce6)](LICENSE) 8 | 9 | > **PatchOpsIII** is a modern, full-featured control center for Call of Duty: Black Ops III modding, maintenance, and performance tuning. 10 | 11 | --- 12 | 13 | ![Program Screenshot](https://github.com/user-attachments/assets/a79e7273-4274-4a43-8d4d-e81a12cbd1ff) 14 | 15 | --- 16 | 17 | ## Table of Contents 18 | - [Overview](#overview) 19 | - [Key Features](#key-features) 20 | - [Mods Tab](#mods-tab) 21 | - [Graphics Tab](#graphics-tab) 22 | - [Advanced Tab](#advanced-tab) 23 | - [Terminal & Logging](#terminal--logging) 24 | - [Installation](#installation) 25 | - [Quick Start](#quick-start) 26 | - [Screenshots](#screenshots) 27 | - [Known Issues](#known-issues) 28 | - [Support](#support) 29 | - [Special Thanks](#special-thanks) 30 | - [License](#license) 31 | - [Star History](#star-history) 32 | 33 | ## Overview 34 | PatchOpsIII streamlines the setup and upkeep of Black Ops III by surfacing popular community tools and quality-of-life tweaks in a single polished interface. The Python application ships with dark/light themes, tabbed navigation (Mods, Graphics, Advanced), and Nuitka builds for Windows and Linux. Whether you are securing your game with T7 Patch, smoothing shader compilation stutter with DXVK, or fine-tuning launch options, PatchOpsIII consolidates every workflow into one cohesive experience. 35 | 36 | ## Key Features 37 | 38 | ### Mods Tab 39 | - **Smart Game Directory Detection:** Automatically locates your Black Ops III installation or lets you browse manually. 40 | - **T7 Patch Management:** Install, update, configure gamertags and colors, apply network passwords, toggle Friends Only mode, deploy LPC fixes, and cleanly uninstall. 41 | - **DXVK-GPLAsync Integration:** Deploy and remove Vulkan-based shader compilation to smooth frametimes by reducing shader cache stutter. 42 | - **Workshop Helper:** One-click access to curated Steam Workshop mods and documentation. 43 | - **Launch Profiles:** Preset command-line configurations for Offline play, [All-around Enhancement Lite](https://steamcommunity.com/sharedfiles/filedetails/?id=2994481309), and [Ultimate Experience Mod](https://steamcommunity.com/sharedfiles/filedetails/?id=2942053577). 44 | 45 | ### Graphics Tab 46 | - **Preset Loader:** Apply curated JSON presets to instantly switch between visual configurations. 47 | - **Convenience Sliders:** Tweak FOV, display mode, resolution, refresh rate, render resolution %, V-Sync, and FPS counters. 48 | - **Intro Skip & FPS Limiter:** Automate `.mkv` renames and adjust FPS limits from 0–1000 for faster load times and smoother gameplay. 49 | 50 | ### Advanced Tab 51 | - **Power Tweaks:** Toggle SmoothFramerate, unlock full VRAM usage, reduce CPU pressure, manage frame latency, and expose hidden graphics options by editing `config.ini` safely. 52 | - **Stutter Fixes:** Automate DirectX DLL renaming to keep shader compilation modern and responsive. 53 | - **Config Safeguards:** Set configuration files read-only to preserve your optimized setup. 54 | 55 | ### Terminal & Logging 56 | - Embedded console view provides live feedback on every action. 57 | - Automatic `PatchOpsIII.log` generation captures a detailed audit trail for troubleshooting and support. 58 | 59 | ## Installation 60 | 1. **Download:** Grab the latest release from the [Releases page](https://github.com/boggedbrush/PatchOpsIII/releases). 61 | 2. **Extract:** Unzip the package to a preferred folder outside of your game directory. 62 | 3. **Run:** Launch `PatchOpsIII.exe` on Windows (or the corresponding binary for other platforms as they become available). 63 | 4. **Dependencies:** The packaged build bundles all required Python dependencies; no additional setup is needed. 64 | 65 | ### Developer Setup 66 | ```bash 67 | # clone the repository 68 | git clone https://github.com/boggedbrush/PatchOpsIII.git 69 | cd PatchOpsIII 70 | 71 | # create a virtual environment 72 | python -m venv .venv 73 | source .venv/bin/activate # On Windows use: .venv\Scripts\activate 74 | 75 | # install dependencies 76 | pip install -r requirements.txt 77 | 78 | # run the application 79 | python main.py 80 | ``` 81 | 82 | ## Quick Start 83 | 1. Launch PatchOpsIII and verify your Black Ops III directory. 84 | 2. Apply the **T7 Patch** to secure multiplayer connectivity and remove RCE vulnerabilities. 85 | 3. Enable **DXVK-GPLAsync** for async shader compilation and smoother frametimes. 86 | 4. Choose a graphics preset or dial in custom display options. 87 | 5. Visit the **Advanced** tab to unlock VRAM, tweak frame latency, and set your config to read-only once satisfied. 88 | 89 | ## Screenshots 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 |
Mods TabGraphics Tab
Advanced Tab
99 | 100 | ## Known Issues 101 | - Full version of the [All-around Enhancement Mod](https://steamcommunity.com/sharedfiles/filedetails/?id=2631943123) currently crashes before the game finishes launching, so it is not exposed as a launch option in PatchOpsIII. 102 | - Launch option stability can vary between systems; experiment to find a stable configuration. 103 | - A few advanced toggles remain in beta testing—report issues via GitHub. 104 | 105 | ## Support 106 | - 📚 Explore detailed usage notes in the [project wiki](wiki/home.md). 107 | - 🐛 Report bugs or request features through [GitHub Issues](https://github.com/boggedbrush/PatchOpsIII/issues). 108 | - 💬 Join the community discussion on Discord *(coming soon)*. 109 | 110 | ## Special Thanks 111 | This project would not be possible without the incredible work of the broader community: 112 | 113 | - **t7patch** – Security and stability backbone for Black Ops III multiplayer. 114 | [https://github.com/shiversoftdev/t7patch](https://github.com/shiversoftdev/t7patch) 115 | - **dxvk-gplasync** – Vulkan translation layer with async shader compilation. 116 | [https://gitlab.com/Ph42oN/dxvk-gplasync](https://gitlab.com/Ph42oN/dxvk-gplasync) 117 | - **ValvePython/vdf** – Reliable Steam VDF parsing utilities used throughout PatchOpsIII. 118 | [https://github.com/ValvePython/vdf](https://github.com/ValvePython/vdf) 119 | 120 | ## License 121 | PatchOpsIII is released under the [MIT License](LICENSE). 122 | 123 | ## Star History 124 | [![Star History Chart](https://api.star-history.com/svg?repos=boggedbrush/PatchOpsIII&type=Date)](https://star-history.com/#boggedbrush/PatchOpsIII&Date) 125 | -------------------------------------------------------------------------------- /.github/workflows/linux-build.yml: -------------------------------------------------------------------------------- 1 | name: Linux Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - Testing 7 | - testing 8 | pull_request: 9 | branches: 10 | - Testing 11 | - testing 12 | workflow_dispatch: 13 | workflow_call: 14 | inputs: 15 | ref: 16 | description: Git ref to build 17 | required: false 18 | type: string 19 | default: '' 20 | outputs: 21 | hash: 22 | description: SHA256 hash of the AppImage 23 | value: ${{ jobs.build-linux.outputs.hash }} 24 | vt_url: 25 | description: VirusTotal URL for the AppImage 26 | value: ${{ jobs.build-linux.outputs.vt_url }} 27 | artifact_name: 28 | description: Name of the produced artifact 29 | value: ${{ jobs.build-linux.outputs.artifact_name }} 30 | 31 | permissions: 32 | contents: read 33 | id-token: write 34 | 35 | jobs: 36 | build-linux: 37 | name: Build Linux AppImage 38 | runs-on: ubuntu-latest 39 | outputs: 40 | hash: ${{ steps.compute_hash.outputs.hash }} 41 | vt_url: ${{ steps.compute_hash.outputs.vt_url }} 42 | artifact_name: PatchOpsIII-linux 43 | defaults: 44 | run: 45 | shell: bash 46 | working-directory: . 47 | env: 48 | OUTPUT_DIR: dist/appimage 49 | APPIMAGE_NAME: PatchOpsIII.AppImage 50 | ZSYNC_NAME: PatchOpsIII.AppImage.zsync 51 | VT_CLI_ARCHIVE: vt-cli.zip 52 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 53 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 54 | VT_API_KEY: ${{ secrets.VT_API_KEY }} 55 | steps: 56 | - uses: actions/checkout@v4 57 | with: 58 | ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} 59 | - name: Determine version from version.py 60 | id: version 61 | run: | 62 | python <<'PY' 63 | import os 64 | import re 65 | import runpy 66 | 67 | data = runpy.run_path('version.py') 68 | raw_version = str(data.get('APP_VERSION', '')).strip() 69 | if not raw_version: 70 | raise SystemExit('APP_VERSION is empty in version.py') 71 | 72 | tagged = raw_version if raw_version.lower().startswith('v') else f'v{raw_version}' 73 | normalized = re.sub(r"[^0-9.]", ".", raw_version) 74 | normalized = re.sub(r"\.+", ".", normalized).strip(".") or "0.0.0" 75 | 76 | with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: 77 | fh.write(f'raw={raw_version}\n') 78 | fh.write(f'tagged={tagged}\n') 79 | fh.write(f'normalized={normalized}\n') 80 | 81 | with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as fh: 82 | fh.write(f'PATCHOPSIII_VERSION={raw_version}\n') 83 | PY 84 | - name: Setup Python 85 | uses: actions/setup-python@v5 86 | with: 87 | python-version: '3.12' 88 | cache: 'pip' 89 | cache-dependency-path: requirements.txt 90 | - name: Cache Nuitka build cache 91 | uses: actions/cache@v4 92 | with: 93 | path: .nuitka-cache 94 | key: nuitka-linux-${{ steps.version.outputs.raw }} 95 | restore-keys: | 96 | nuitka-linux- 97 | - name: Cache pip downloads 98 | uses: actions/cache@v4 99 | with: 100 | path: ~/.cache/pip 101 | key: ${{ runner.os }}-pip-3.12-${{ hashFiles('requirements.txt') }} 102 | restore-keys: | 103 | ${{ runner.os }}-pip-3.12- 104 | ${{ runner.os }}-pip- 105 | - name: Cache linuxdeploy toolchain 106 | uses: actions/cache@v4 107 | with: 108 | path: build/linuxdeploy-tools 109 | key: linuxdeploy-${{ runner.os }} 110 | - name: Install Python dependencies 111 | run: | 112 | python -m pip install -U pip 113 | python -m pip install -r requirements.txt 114 | python -m pip install -U nuitka pyside6 115 | - name: Build AppImage 116 | run: bash scripts/build_appimage.sh 117 | - name: Verify AppImage output 118 | run: | 119 | target="$OUTPUT_DIR/$APPIMAGE_NAME" 120 | if [ ! -f "$target" ]; then 121 | echo "$target was not produced" >&2 122 | ls -R "$OUTPUT_DIR" || true 123 | exit 1 124 | fi 125 | "$target" --appimage-updateinformation 126 | - name: Install cosign (optional) 127 | if: ${{ env.COSIGN_PRIVATE_KEY != '' }} 128 | uses: sigstore/cosign-installer@v3.4.0 129 | - name: Sign AppImage with cosign 130 | if: ${{ env.COSIGN_PRIVATE_KEY != '' }} 131 | env: 132 | COSIGN_EXPERIMENTAL: "1" 133 | run: | 134 | target="$OUTPUT_DIR/$APPIMAGE_NAME" 135 | if [ ! -f "$target" ]; then 136 | echo "$target was not produced" >&2 137 | exit 1 138 | fi 139 | printf "%s" "$COSIGN_PRIVATE_KEY" > cosign.key 140 | cosign sign-blob --yes --key cosign.key --output-signature "$target.sig" --output-certificate "$target.pem" "$target" 141 | rm -f cosign.key 142 | - name: Compute AppImage hash 143 | id: compute_hash 144 | run: | 145 | target="$OUTPUT_DIR/$APPIMAGE_NAME" 146 | if [ ! -f "$target" ]; then 147 | echo "$target was not produced" >&2 148 | exit 1 149 | fi 150 | hash=$(sha256sum "$target" | cut -d ' ' -f1) 151 | printf "%s\n" "$hash" > "$OUTPUT_DIR/hash.log" 152 | echo "SHA256: $hash" 153 | vt_url="https://www.virustotal.com/gui/file/$hash" 154 | echo "LINUX_VT_URL=$vt_url" >> "$GITHUB_ENV" 155 | printf "hash=%s\n" "$hash" >> "$GITHUB_OUTPUT" 156 | printf "vt_url=%s\n" "$vt_url" >> "$GITHUB_OUTPUT" 157 | - name: Determine VirusTotal CLI release 158 | id: vt_release 159 | if: ${{ env.VT_API_KEY != '' }} 160 | run: | 161 | set -euo pipefail 162 | python <<'PY' 163 | import json 164 | import os 165 | import urllib.request 166 | 167 | url = 'https://api.github.com/repos/VirusTotal/vt-cli/releases/latest' 168 | with urllib.request.urlopen(url) as response: 169 | data = json.load(response) 170 | tag = data.get('tag_name') 171 | try: 172 | asset_url = next(asset['browser_download_url'] for asset in data['assets'] if asset['name'] == 'Linux64.zip') 173 | except StopIteration: 174 | raise SystemExit('Linux64.zip asset not found in VirusTotal CLI release metadata') 175 | if not tag: 176 | raise SystemExit('Release tag missing from VirusTotal CLI metadata') 177 | with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: 178 | fh.write(f'tag={tag}\n') 179 | fh.write(f'url={asset_url}\n') 180 | PY 181 | - name: Cache VirusTotal CLI 182 | if: ${{ env.VT_API_KEY != '' }} 183 | id: cache-vt 184 | uses: actions/cache@v4 185 | with: 186 | path: | 187 | vt 188 | ${{ env.VT_CLI_ARCHIVE }} 189 | key: vt-${{ runner.os }}-${{ steps.vt_release.outputs.tag }} 190 | - name: Download VirusTotal CLI 191 | if: ${{ env.VT_API_KEY != '' && steps.cache-vt.outputs.cache-hit != 'true' }} 192 | run: | 193 | set -euo pipefail 194 | curl -sSL "${{ steps.vt_release.outputs.url }}" -o "$VT_CLI_ARCHIVE" 195 | unzip -q -o "$VT_CLI_ARCHIVE" 196 | - name: Ensure VirusTotal CLI executable 197 | if: ${{ env.VT_API_KEY != '' }} 198 | run: | 199 | if [ -f vt ]; then 200 | chmod +x vt 201 | else 202 | echo 'VirusTotal CLI binary not found' >&2 203 | exit 1 204 | fi 205 | - name: VirusTotal scan (optional) 206 | if: ${{ env.VT_API_KEY != '' }} 207 | env: 208 | VT_API_KEY: ${{ env.VT_API_KEY }} 209 | run: | 210 | ./vt scan file "$OUTPUT_DIR/$APPIMAGE_NAME" --apikey "$VT_API_KEY" 211 | - name: Upload AppImage artifact 212 | uses: actions/upload-artifact@v4 213 | with: 214 | name: PatchOpsIII-linux 215 | path: | 216 | ${{ env.OUTPUT_DIR }}/${{ env.APPIMAGE_NAME }} 217 | ${{ env.OUTPUT_DIR }}/${{ env.ZSYNC_NAME }} 218 | ${{ env.OUTPUT_DIR }}/hash.log 219 | ${{ env.OUTPUT_DIR }}/${{ env.APPIMAGE_NAME }}.sig 220 | ${{ env.OUTPUT_DIR }}/${{ env.APPIMAGE_NAME }}.pem 221 | if-no-files-found: ignore 222 | -------------------------------------------------------------------------------- /.github/workflows/windows-build.yml: -------------------------------------------------------------------------------- 1 | name: Windows Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - Testing 7 | - testing 8 | pull_request: 9 | branches: 10 | - Testing 11 | - testing 12 | workflow_dispatch: 13 | workflow_call: 14 | inputs: 15 | ref: 16 | description: Git ref to build 17 | required: false 18 | type: string 19 | default: '' 20 | outputs: 21 | hash: 22 | description: SHA256 hash of the Windows executable 23 | value: ${{ jobs.build-windows.outputs.hash }} 24 | vt_url: 25 | description: VirusTotal URL for the Windows executable 26 | value: ${{ jobs.build-windows.outputs.vt_url }} 27 | artifact_name: 28 | description: Name of the produced artifact 29 | value: ${{ jobs.build-windows.outputs.artifact_name }} 30 | 31 | permissions: 32 | contents: read 33 | id-token: write 34 | 35 | jobs: 36 | build-windows: 37 | name: Build Windows executable 38 | runs-on: windows-latest 39 | outputs: 40 | hash: ${{ steps.compute_hash.outputs.hash }} 41 | vt_url: ${{ steps.compute_hash.outputs.vt_url }} 42 | artifact_name: PatchOpsIII-windows 43 | defaults: 44 | run: 45 | shell: pwsh 46 | working-directory: . 47 | env: 48 | WINDOWS_DIST_DIR: dist\windows 49 | WINDOWS_BINARY: PatchOpsIII.exe 50 | VT_CLI_ARCHIVE: vt-cli-latest.zip 51 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 52 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 53 | VT_API_KEY: ${{ secrets.VT_API_KEY }} 54 | steps: 55 | - uses: actions/checkout@v4 56 | with: 57 | ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} 58 | - name: Setup Python 59 | uses: actions/setup-python@v5 60 | with: 61 | python-version: '3.11' 62 | cache: 'pip' 63 | cache-dependency-path: requirements.txt 64 | - name: Cache pip downloads 65 | uses: actions/cache@v4 66 | with: 67 | path: ~\AppData\Local\pip\Cache 68 | key: ${{ runner.os }}-pip-3.11-${{ hashFiles('requirements.txt') }} 69 | restore-keys: | 70 | ${{ runner.os }}-pip-3.11- 71 | ${{ runner.os }}-pip- 72 | - name: Determine version from version.py 73 | id: version 74 | shell: bash 75 | run: | 76 | python - <<'PY' 77 | import os 78 | import re 79 | import runpy 80 | 81 | data = runpy.run_path('version.py') 82 | raw_version = str(data.get('APP_VERSION', '')).strip() 83 | if not raw_version: 84 | raise SystemExit('APP_VERSION is empty in version.py') 85 | 86 | tagged = raw_version if raw_version.lower().startswith('v') else f'v{raw_version}' 87 | normalized = re.sub(r"[^0-9.]", ".", raw_version) 88 | normalized = re.sub(r"\.+", ".", normalized).strip(".") or "0.0.0" 89 | 90 | with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: 91 | fh.write(f'raw={raw_version}\n') 92 | fh.write(f'tagged={tagged}\n') 93 | fh.write(f'normalized={normalized}\n') 94 | 95 | with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as fh: 96 | fh.write(f'PATCHOPSIII_VERSION={raw_version}\n') 97 | PY 98 | - name: Install dependencies 99 | run: | 100 | python -m pip install -r requirements.txt 101 | python -m pip install --upgrade nuitka 102 | - name: Cache Nuitka build artifacts 103 | id: cache-nuitka 104 | uses: actions/cache@v4 105 | with: 106 | path: | 107 | PatchOpsIII.build 108 | PatchOpsIII.dist 109 | PatchOpsIII.onefile-build 110 | .nuitka-cache 111 | key: nuitka-${{ runner.os }}-${{ steps.version.outputs.raw }} 112 | - name: Prepare build directories 113 | run: | 114 | if (Test-Path 'dist') { Remove-Item 'dist' -Recurse -Force } 115 | if (Test-Path 'build') { Remove-Item 'build' -Recurse -Force } 116 | if ('${{ steps.cache-nuitka.outputs.cache-hit }}' -ne 'true') { 117 | if (Test-Path 'PatchOpsIII.build') { Remove-Item 'PatchOpsIII.build' -Recurse -Force } 118 | if (Test-Path 'PatchOpsIII.dist') { Remove-Item 'PatchOpsIII.dist' -Recurse -Force } 119 | if (Test-Path 'PatchOpsIII.onefile-build') { Remove-Item 'PatchOpsIII.onefile-build' -Recurse -Force } 120 | if (Test-Path '.nuitka-cache') { Remove-Item '.nuitka-cache' -Recurse -Force } 121 | } 122 | if (Test-Path 'PatchOpsIII.spec') { Remove-Item 'PatchOpsIII.spec' -Force } 123 | New-Item -ItemType Directory -Force -Path $env:WINDOWS_DIST_DIR | Out-Null 124 | - name: Build executable with Nuitka 125 | run: | 126 | python -m nuitka ` 127 | --onefile ` 128 | --onefile-no-compression ` 129 | --windows-disable-console ` 130 | --clang ` 131 | --lto=yes ` 132 | --assume-yes-for-downloads ` 133 | --enable-plugins=pyside6 ` 134 | --include-qt-plugins=sensible,platforms ` 135 | --nofollow-import-to=PySide6.QtWebEngineCore ` 136 | --nofollow-import-to=PySide6.QtWebEngineWidgets ` 137 | --nofollow-import-to=PySide6.QtWebEngine ` 138 | --nofollow-import-to=PySide6.QtWebEngineQuick ` 139 | --nofollow-import-to=PySide6.QtMultimedia ` 140 | --nofollow-import-to=PySide6.QtMultimediaWidgets ` 141 | --nofollow-import-to=PySide6.QtOpenGLWidgets ` 142 | --include-package=vdf ` 143 | --include-package=requests ` 144 | --include-data-file=presets.json=presets.json ` 145 | --include-data-file=PatchOpsIII.ico=PatchOpsIII.ico ` 146 | --windows-icon-from-ico=PatchOpsIII.ico ` 147 | --company-name=boggedbrush ` 148 | --product-name=PatchOpsIII ` 149 | --file-version=${{ steps.version.outputs.normalized }} ` 150 | --product-version=${{ steps.version.outputs.normalized }} ` 151 | --output-dir=$env:WINDOWS_DIST_DIR ` 152 | --output-filename=$env:WINDOWS_BINARY ` 153 | main.py 154 | - name: Ensure executable exists 155 | run: | 156 | $target = Join-Path $env:WINDOWS_DIST_DIR $env:WINDOWS_BINARY 157 | if (-not (Test-Path $target)) { 158 | throw "$target was not produced" 159 | } 160 | - name: Install cosign (optional) 161 | if: ${{ env.COSIGN_PRIVATE_KEY != '' }} 162 | uses: sigstore/cosign-installer@v3.4.0 163 | - name: Sign executable with cosign 164 | if: ${{ env.COSIGN_PRIVATE_KEY != '' }} 165 | env: 166 | COSIGN_EXPERIMENTAL: "1" 167 | run: | 168 | $target = Join-Path $env:WINDOWS_DIST_DIR $env:WINDOWS_BINARY 169 | if (-not (Test-Path $target)) { 170 | throw "$target was not produced" 171 | } 172 | Set-Content -Path cosign.key -Value $env:COSIGN_PRIVATE_KEY -NoNewline 173 | $signature = Join-Path $env:WINDOWS_DIST_DIR 'PatchOpsIII.sig' 174 | cosign sign-blob --yes --key cosign.key --output-signature $signature $target 175 | Remove-Item cosign.key 176 | - name: Compute Windows hash 177 | id: compute_hash 178 | run: | 179 | $target = Join-Path $env:WINDOWS_DIST_DIR $env:WINDOWS_BINARY 180 | if (-not (Test-Path $target)) { 181 | throw "$target was not produced" 182 | } 183 | $hash = (Get-FileHash -Algorithm SHA256 -Path $target).Hash 184 | Set-Content -Path (Join-Path $env:WINDOWS_DIST_DIR 'hash.log') -Value $hash 185 | Write-Host "SHA256: $hash" 186 | $vtUrl = "https://www.virustotal.com/gui/file/$hash" 187 | "WINDOWS_VT_URL=$vtUrl" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append 188 | "hash=$hash" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append 189 | "vt_url=$vtUrl" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append 190 | - name: Determine VirusTotal CLI release 191 | id: vt_release 192 | if: ${{ env.VT_API_KEY != '' }} 193 | shell: pwsh 194 | run: | 195 | $release = Invoke-RestMethod -Uri https://api.github.com/repos/VirusTotal/vt-cli/releases/latest 196 | $tag = $release.tag_name 197 | if (-not $tag) { 198 | throw 'Release tag missing from VirusTotal CLI metadata' 199 | } 200 | $asset = $release.assets | Where-Object { $_.name -eq 'Windows64.zip' } | Select-Object -First 1 201 | if (-not $asset) { 202 | throw 'Windows64.zip asset not found in VirusTotal CLI release metadata' 203 | } 204 | "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append 205 | "url=$($asset.browser_download_url)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append 206 | - name: Cache VirusTotal CLI 207 | if: ${{ env.VT_API_KEY != '' }} 208 | id: cache-vt 209 | uses: actions/cache@v4 210 | with: 211 | path: | 212 | vt.exe 213 | ${{ env.VT_CLI_ARCHIVE }} 214 | key: vt-${{ runner.os }}-${{ steps.vt_release.outputs.tag }} 215 | - name: Download VirusTotal CLI 216 | if: ${{ env.VT_API_KEY != '' && steps.cache-vt.outputs.cache-hit != 'true' }} 217 | shell: pwsh 218 | run: | 219 | Invoke-WebRequest -Uri '${{ steps.vt_release.outputs.url }}' -OutFile $env:VT_CLI_ARCHIVE 220 | Expand-Archive -Path $env:VT_CLI_ARCHIVE -DestinationPath . -Force 221 | - name: Ensure VirusTotal CLI executable 222 | if: ${{ env.VT_API_KEY != '' }} 223 | shell: pwsh 224 | run: | 225 | if (-not (Test-Path 'vt.exe')) { 226 | throw 'VirusTotal CLI binary vt.exe not found' 227 | } 228 | - name: VirusTotal scan (optional) 229 | if: ${{ env.VT_API_KEY != '' }} 230 | env: 231 | VT_API_KEY: ${{ env.VT_API_KEY }} 232 | run: | 233 | $target = Join-Path $env:WINDOWS_DIST_DIR $env:WINDOWS_BINARY 234 | .\vt.exe scan file $target --apikey $env:VT_API_KEY 235 | - name: Upload Windows artifact 236 | uses: actions/upload-artifact@v4 237 | with: 238 | name: PatchOpsIII-windows 239 | path: | 240 | ${{ env.WINDOWS_DIST_DIR }}/${{ env.WINDOWS_BINARY }} 241 | ${{ env.WINDOWS_DIST_DIR }}/hash.log 242 | ${{ env.WINDOWS_DIST_DIR }}/PatchOpsIII.sig 243 | if-no-files-found: ignore 244 | -------------------------------------------------------------------------------- /dxvk_manager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import importlib.util 3 | import os 4 | import shutil 5 | import sys 6 | import tarfile 7 | import zipfile 8 | 9 | import requests 10 | from urllib.parse import urlsplit 11 | from PySide6.QtWidgets import QMessageBox, QWidget, QGroupBox, QHBoxLayout, QPushButton, QLabel, QVBoxLayout, QSizePolicy 12 | from PySide6.QtCore import QEvent 13 | from utils import write_log 14 | 15 | # ---------- DXVK Helper Functions (unchanged) ---------- 16 | 17 | DXVK_ASYNC_FILES = ["dxgi.dll", "d3d11.dll"] 18 | 19 | def get_latest_release(): 20 | api_url = "https://gitlab.com/api/v4/projects/Ph42oN%2Fdxvk-gplasync/releases" 21 | r = requests.get(api_url) 22 | r.raise_for_status() 23 | releases = r.json() 24 | if not releases: 25 | raise RuntimeError("No releases returned from DXVK-GPLAsync API") 26 | return releases[0] # Assumes releases are sorted latest first 27 | 28 | def get_download_url(release): 29 | assets = release.get("assets", {}) 30 | links = assets.get("links", []) 31 | if links: 32 | # Prefer archives we can extract natively before falling back to anything else 33 | preferred_order = (".zip", ".tar.xz", ".tar.gz", ".tar.bz2", ".tar.zst", ".tzst") 34 | for suffix in preferred_order: 35 | for link in links: 36 | url = link.get("url", "") 37 | if url.lower().endswith(suffix): 38 | return url 39 | return links[0]["url"] 40 | sources = assets.get("sources", []) 41 | if sources: 42 | for source in sources: 43 | if source.get("format") == "zip": 44 | return source.get("url") 45 | return sources[0].get("url") 46 | raise RuntimeError("No downloadable asset found in DXVK-GPLAsync release metadata") 47 | 48 | 49 | def _load_zstandard(): 50 | if "zstandard" in sys.modules: 51 | return sys.modules["zstandard"] 52 | 53 | spec = importlib.util.find_spec("zstandard") 54 | if spec is None: 55 | raise ModuleNotFoundError( 56 | "The 'zstandard' package is required to unpack .tar.zst archives." 57 | ) 58 | 59 | module = importlib.util.module_from_spec(spec) 60 | loader = spec.loader 61 | if loader is None: 62 | raise ImportError("Unable to load the 'zstandard' module") 63 | loader.exec_module(module) 64 | sys.modules["zstandard"] = module 65 | return module 66 | 67 | 68 | def extract_archive(archive_path, extract_dir): 69 | lower_name = archive_path.lower() 70 | if lower_name.endswith(".zip"): 71 | with zipfile.ZipFile(archive_path, "r") as zip_ref: 72 | zip_ref.extractall(extract_dir) 73 | return 74 | 75 | if lower_name.endswith((".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")): 76 | with tarfile.open(archive_path, "r:*") as tar: 77 | tar.extractall(path=extract_dir) 78 | return 79 | 80 | if lower_name.endswith((".tar.zst", ".tzst")): 81 | zstandard = _load_zstandard() 82 | with open(archive_path, "rb") as compressed: 83 | dctx = zstandard.ZstdDecompressor() 84 | with dctx.stream_reader(compressed) as reader: 85 | with tarfile.open(fileobj=reader, mode="r|") as tar: 86 | tar.extractall(path=extract_dir) 87 | return 88 | 89 | # Let shutil attempt to handle any other known formats 90 | shutil.unpack_archive(archive_path, extract_dir) 91 | 92 | def download_file(url, filename): 93 | print(f"Downloading from {url}") 94 | with requests.get(url, stream=True) as r: 95 | r.raise_for_status() 96 | # Extract filename from URL 97 | parsed_url = urlsplit(url) 98 | original_filename = os.path.basename(parsed_url.path) 99 | 100 | # Use the original filename if available, otherwise use the provided filename 101 | if original_filename: 102 | final_filename = os.path.join(os.path.dirname(filename), original_filename) 103 | else: 104 | final_filename = filename 105 | 106 | with open(final_filename, "wb") as f: 107 | for chunk in r.iter_content(chunk_size=8192): 108 | if chunk: 109 | f.write(chunk) 110 | print(f"Downloaded file saved as: {final_filename}") 111 | return final_filename # Return the modified filename 112 | 113 | def is_dxvk_async_installed(game_dir): 114 | return all(os.path.exists(os.path.join(game_dir, f)) for f in DXVK_ASYNC_FILES) 115 | 116 | def manage_dxvk_async(game_dir, action, log_widget, mod_files_dir): 117 | if action == "Uninstall": 118 | dxvk_installed = all(os.path.exists(os.path.join(game_dir, f)) for f in DXVK_ASYNC_FILES) 119 | if dxvk_installed: 120 | write_log("DXVK-GPLAsync is detected. Uninstalling...", "Info", log_widget) 121 | for f in DXVK_ASYNC_FILES: 122 | path = os.path.join(game_dir, f) 123 | try: 124 | if os.path.exists(path): 125 | os.remove(path) 126 | write_log(f"Removed '{f}'.", "Success", log_widget) 127 | except Exception as e: 128 | write_log(f"Failed to remove '{f}': {str(e)}", "Error", log_widget) 129 | # Remove dxvk.conf if it exists 130 | conf_path = os.path.join(game_dir, "dxvk.conf") 131 | if os.path.exists(conf_path): 132 | try: 133 | os.remove(conf_path) 134 | write_log("Removed dxvk.conf.", "Success", log_widget) 135 | except Exception as e: 136 | write_log(f"Failed to remove dxvk.conf: {str(e)}", "Error", log_widget) 137 | write_log("DXVK-GPLAsync has been uninstalled.", "Success", log_widget) 138 | else: 139 | write_log("DXVK-GPLAsync is not installed.", "Info", log_widget) 140 | elif action == "Install": 141 | dxvk_installed = all(os.path.exists(os.path.join(game_dir, f)) for f in DXVK_ASYNC_FILES) 142 | if dxvk_installed: 143 | write_log("DXVK-GPLAsync is already installed.", "Info", log_widget) 144 | return 145 | write_log("DXVK-GPLAsync can reduce stuttering by using async shader compilation.", "Info", log_widget) 146 | if QMessageBox.question(None, "Install DXVK-GPLAsync", "Do you want to install DXVK-GPLAsync?", 147 | QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes: 148 | try: 149 | x64_dir = None 150 | release = get_latest_release() 151 | write_log("Latest release: " + release.get("name", release.get("tag_name", "Unknown")), "Info", log_widget) 152 | dxvk_url = get_download_url(release) 153 | dxvk_archive = download_file(dxvk_url, os.path.join(mod_files_dir, "dxvk-gplasync")) 154 | write_log("Downloaded DXVK-GPLAsync successfully.", "Success", log_widget) 155 | 156 | extract_dir = os.path.join(mod_files_dir, "dxvk_extracted") 157 | if os.path.exists(extract_dir): 158 | shutil.rmtree(extract_dir) 159 | os.makedirs(extract_dir, exist_ok=True) 160 | 161 | # Extract files based on archive type 162 | try: 163 | extract_archive(dxvk_archive, extract_dir) 164 | write_log("Extracted DXVK-GPLAsync successfully.", "Success", log_widget) 165 | except Exception as e: 166 | write_log(f"Failed to extract DXVK-GPLAsync: {str(e)}", "Error", log_widget) 167 | return 168 | 169 | # Look for the directory containing DXVK files recursively 170 | x64_dir = None 171 | for root, dirs, files in os.walk(extract_dir): 172 | if all(f in files for f in DXVK_ASYNC_FILES): 173 | x64_dir = root 174 | break 175 | 176 | if not x64_dir: 177 | write_log("Required DXVK files (dxgi.dll, d3d11.dll) not found in extracted DXVK-GPLAsync directory.", "Error", log_widget) 178 | return 179 | 180 | # Install DXVK files 181 | for file in DXVK_ASYNC_FILES: 182 | src = os.path.join(x64_dir, file) 183 | dst = os.path.join(game_dir, file) 184 | try: 185 | if os.path.exists(src): 186 | shutil.copy2(src, dst) 187 | write_log(f"Installed {file}.", "Success", log_widget) 188 | else: 189 | write_log(f"Source file {file} not found.", "Error", log_widget) 190 | return 191 | except Exception as e: 192 | write_log(f"Failed to install {file}: {str(e)}", "Error", log_widget) 193 | return 194 | 195 | # Write dxvk.conf 196 | try: 197 | conf_path = os.path.join(game_dir, "dxvk.conf") 198 | with open(conf_path, "w") as conf_file: 199 | conf_file.write("dxvk.enableAsync=true\n") 200 | conf_file.write("dxvk.gplAsyncCache=true\n") 201 | write_log("Created dxvk.conf with async settings.", "Success", log_widget) 202 | except Exception as e: 203 | write_log(f"Failed to create dxvk.conf: {str(e)}", "Error", log_widget) 204 | return 205 | 206 | write_log("DXVK-GPLAsync installed successfully.", "Success", log_widget) 207 | 208 | except Exception as e: 209 | write_log(f"Error during DXVK-GPLAsync installation: {str(e)}", "Error", log_widget) 210 | finally: 211 | # Clean up temporary files 212 | try: 213 | if os.path.exists(dxvk_archive): 214 | os.remove(dxvk_archive) 215 | if os.path.exists(extract_dir): 216 | shutil.rmtree(extract_dir) 217 | except Exception as e: 218 | write_log(f"Warning: Could not clean up temporary files: {str(e)}", "Warning", log_widget) 219 | else: 220 | write_log("DXVK-GPLAsync installation canceled by user.", "Info", log_widget) 221 | 222 | # ---------- DXVK GUI Widget ---------- 223 | 224 | class DXVKWidget(QWidget): 225 | def __init__(self, mod_files_dir, parent=None): 226 | super().__init__(parent) 227 | self.mod_files_dir = mod_files_dir 228 | self.game_dir = None 229 | self.log_widget = None 230 | self.group = QGroupBox("DXVK-GPLAsync Management") 231 | self.group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 232 | self.init_ui() 233 | self.update_theme() 234 | 235 | @property 236 | def groupbox(self): 237 | return self.group 238 | 239 | def init_ui(self): 240 | self.group = QGroupBox("DXVK-GPLAsync Management") 241 | layout = QHBoxLayout(self.group) 242 | # Match T7 Patch margins/spacing: 243 | layout.setContentsMargins(5, 5, 5, 5) 244 | layout.setSpacing(5) 245 | self.install_btn = QPushButton("Install DXVK-GPLAsync") 246 | self.install_btn.clicked.connect(lambda: self.manage_dxvk("Install")) 247 | self.uninstall_btn = QPushButton("Uninstall DXVK-GPLAsync") 248 | self.uninstall_btn.clicked.connect(lambda: self.manage_dxvk("Uninstall")) 249 | self.status_label = QLabel("") 250 | layout.addWidget(self.install_btn) 251 | layout.addWidget(self.uninstall_btn) 252 | layout.addWidget(self.status_label) 253 | main_layout = QVBoxLayout(self) 254 | main_layout.addWidget(self.group) 255 | 256 | def update_theme(self): 257 | is_dark = self.palette().window().color().lightness() < 128 258 | if (is_dark): 259 | control_color = "#2D2D30" 260 | fore_color = "#FFFFFF" 261 | else: 262 | control_color = "#F0F0F0" 263 | fore_color = "#000000" 264 | 265 | # Update button styles 266 | for btn in [self.install_btn, self.uninstall_btn]: 267 | btn.setStyleSheet(f"background-color: {control_color}; color: {fore_color};") 268 | 269 | # Update label style 270 | self.status_label.setStyleSheet(f"color: {fore_color};") 271 | 272 | def changeEvent(self, event): 273 | if event.type() == QEvent.Type.PaletteChange: 274 | self.update_theme() 275 | super().changeEvent(event) 276 | 277 | def set_game_directory(self, game_dir): 278 | self.game_dir = game_dir 279 | self.update_status() 280 | 281 | def set_log_widget(self, log_widget): 282 | self.log_widget = log_widget 283 | 284 | def update_status(self): 285 | if self.game_dir and os.path.exists(self.game_dir): 286 | if is_dxvk_async_installed(self.game_dir): 287 | self.status_label.setText("DXVK-GPLAsync: Installed") 288 | else: 289 | self.status_label.setText("DXVK-GPLAsync: Not Installed") 290 | else: 291 | self.status_label.setText("Game directory not set") 292 | 293 | def manage_dxvk(self, action): 294 | if not self.game_dir or not os.path.exists(self.game_dir): 295 | write_log("Game directory does not exist.", "Error", self.log_widget) 296 | return 297 | manage_dxvk_async(self.game_dir, action, self.log_widget, self.mod_files_dir) 298 | self.update_status() 299 | 300 | -------------------------------------------------------------------------------- /.github/workflows/release-stable.yml: -------------------------------------------------------------------------------- 1 | name: Release Stable 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release_notes_file: 7 | description: | 8 | Release notes file name (leave blank or set to "latest" to auto-select the newest notes). 9 | Provide a file that lives under `Release Notes/`. 10 | required: false 11 | type: string 12 | default: latest 13 | source_ref: 14 | description: Source branch or tag to tag from 15 | type: string 16 | default: main 17 | create_tag: 18 | description: Create or update the tag before building (true/false) 19 | type: string 20 | default: "true" 21 | 22 | permissions: 23 | contents: write 24 | id-token: write 25 | 26 | jobs: 27 | prepare: 28 | name: Ensure tag 29 | runs-on: ubuntu-latest 30 | outputs: 31 | tag: ${{ steps.tag.outputs.tag }} 32 | release_title: ${{ steps.metadata.outputs.release_title }} 33 | notes_file: ${{ steps.metadata.outputs.notes_file }} 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | - name: Determine version from version.py 40 | id: version 41 | run: | 42 | python <<'PY' 43 | import os 44 | import re 45 | import runpy 46 | 47 | data = runpy.run_path('version.py') 48 | raw_version = str(data.get('APP_VERSION', '')).strip() 49 | if not raw_version: 50 | raise SystemExit('APP_VERSION is empty in version.py') 51 | 52 | tagged = raw_version if raw_version.lower().startswith('v') else f'v{raw_version}' 53 | normalized = re.sub(r"[^0-9.]", ".", raw_version) 54 | normalized = re.sub(r"\.+", ".", normalized).strip(".") or "0.0.0" 55 | 56 | with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: 57 | fh.write(f'raw={raw_version}\n') 58 | fh.write(f'tagged={tagged}\n') 59 | fh.write(f'normalized={normalized}\n') 60 | 61 | with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as fh: 62 | fh.write(f'PATCHOPSIII_VERSION={raw_version}\n') 63 | PY 64 | - name: Determine release metadata 65 | id: metadata 66 | env: 67 | RELEASE_NOTES_FILE: ${{ inputs.release_notes_file }} 68 | PROJECT_VERSION: ${{ steps.version.outputs.raw }} 69 | run: | 70 | python <<'PY' 71 | import os 72 | import re 73 | from pathlib import Path 74 | 75 | choice = os.environ.get("RELEASE_NOTES_FILE", "").strip() 76 | 77 | repo_root = Path.cwd() 78 | notes_dir = repo_root / "Release Notes" 79 | if not notes_dir.is_dir(): 80 | raise SystemExit("Release Notes directory not found in the repository.") 81 | 82 | candidates = [ 83 | path.relative_to(repo_root) 84 | for path in notes_dir.rglob("*.md") 85 | if path.is_file() and path.name.lower() != "template.md" 86 | ] 87 | 88 | if not candidates: 89 | raise SystemExit("No release notes files found under 'Release Notes/'.") 90 | 91 | metadata = {} 92 | version_regex = re.compile(r"\bv?(?P[0-9][\w.\-]*)", re.IGNORECASE) 93 | 94 | def load_metadata(rel_path: Path): 95 | abs_path = repo_root / rel_path 96 | heading = None 97 | with abs_path.open("r", encoding="utf-8") as handle: 98 | for raw_line in handle: 99 | stripped = raw_line.strip() 100 | if stripped.startswith("#"): 101 | heading = stripped.lstrip("#").strip() 102 | break 103 | 104 | version_token = None 105 | for target in (heading or "", rel_path.stem): 106 | if not target: 107 | continue 108 | match = version_regex.search(target) 109 | if match: 110 | version_token = match.group(0) 111 | if not version_token.lower().startswith("v"): 112 | version_token = f"v{match.group('body')}" 113 | break 114 | 115 | metadata[rel_path] = {"heading": heading, "version": version_token} 116 | return metadata[rel_path] 117 | 118 | for candidate in candidates: 119 | load_metadata(candidate) 120 | 121 | def sort_key(rel_path: Path): 122 | info = metadata[rel_path] 123 | token = (info["version"] or "").lower() 124 | if token.startswith("v"): 125 | token = token[1:] 126 | base_part, _, suffix_part = token.partition("-") 127 | number_values = tuple(int(part) for part in re.findall(r"\d+", base_part)) or (0,) 128 | stability = 1 if not suffix_part else 0 129 | return (number_values, stability, token, rel_path.name.lower()) 130 | 131 | normalized_choice = choice.casefold() 132 | selected_rel = None 133 | if normalized_choice and normalized_choice != "latest": 134 | for candidate in candidates: 135 | candidate_names = { 136 | candidate.name.casefold(), 137 | candidate.stem.casefold(), 138 | candidate.as_posix().casefold(), 139 | } 140 | candidate_version = metadata[candidate]["version"] 141 | if candidate_version: 142 | version_key = candidate_version.casefold() 143 | candidate_names.add(version_key) 144 | candidate_names.add(version_key.removeprefix("v")) 145 | heading = metadata[candidate]["heading"] 146 | if heading: 147 | candidate_names.add(heading.casefold()) 148 | candidate_names.add(heading.replace("Release Notes", "").strip().casefold()) 149 | if normalized_choice in candidate_names: 150 | selected_rel = candidate 151 | break 152 | 153 | if selected_rel is None: 154 | raw_path = Path(choice) 155 | candidate_paths = [] 156 | if raw_path.is_absolute(): 157 | candidate_paths.append(raw_path) 158 | else: 159 | candidate_paths.append(repo_root / raw_path) 160 | candidate_paths.append(notes_dir / raw_path) 161 | if raw_path.suffix == "": 162 | candidate_paths.append((repo_root / raw_path).with_suffix(".md")) 163 | candidate_paths.append((notes_dir / raw_path).with_suffix(".md")) 164 | 165 | selected_abs = None 166 | seen = set() 167 | for possible in candidate_paths: 168 | if possible is None: 169 | continue 170 | resolved = possible.resolve() 171 | key = resolved.as_posix() 172 | if key in seen: 173 | continue 174 | seen.add(key) 175 | if resolved.is_file(): 176 | selected_abs = resolved 177 | break 178 | 179 | if selected_abs is None: 180 | available = ", ".join(sorted(path.name for path in candidates)) 181 | raise SystemExit( 182 | f"Release notes file not found for selection '{choice}'. Available files: {available}" 183 | ) 184 | 185 | try: 186 | selected_abs.relative_to(notes_dir.resolve()) 187 | except ValueError as exc: 188 | raise SystemExit("Release notes must live under 'Release Notes'.") from exc 189 | 190 | if selected_abs.name.lower() == "template.md": 191 | raise SystemExit("Template release notes cannot be used for stable releases.") 192 | 193 | selected_rel = selected_abs.relative_to(repo_root.resolve()) 194 | load_metadata(selected_rel) 195 | 196 | if selected_rel is None: 197 | selected_rel = max(candidates, key=sort_key) 198 | 199 | selected_abs = repo_root / selected_rel 200 | info = metadata[selected_rel] 201 | version_token = os.environ.get("PROJECT_VERSION", "").strip() 202 | if not version_token: 203 | raise SystemExit("PROJECT_VERSION was not provided from version.py.") 204 | 205 | if not version_token.lower().startswith("v"): 206 | version_token = f"v{version_token}" 207 | 208 | tag_name = version_token 209 | notes_version = (info.get("version") or "").strip() 210 | 211 | if not notes_version: 212 | raise SystemExit( 213 | f"Selected release notes '{selected_rel.as_posix()}' do not contain a version heading." 214 | ) 215 | 216 | if notes_version.lower() != tag_name.lower(): 217 | raise SystemExit( 218 | f"Release notes version '{notes_version}' does not match PROJECT_VERSION '{tag_name}'. " 219 | "Update version.py or choose matching release notes." 220 | ) 221 | release_title = f"PatchOpsIII {tag_name}" 222 | 223 | print(f"Selected release notes: {selected_rel.as_posix()}") 224 | 225 | summary_path = os.environ.get("GITHUB_STEP_SUMMARY") 226 | if summary_path: 227 | lines = [ 228 | "# Available release notes", 229 | "", 230 | "| File | Version | Heading |", 231 | "| --- | --- | --- |", 232 | ] 233 | for candidate in sorted(candidates, key=sort_key, reverse=True): 234 | candidate_info = metadata[candidate] 235 | lines.append( 236 | f"| `{candidate.as_posix()}` | {candidate_info['version'] or '—'} | {candidate_info['heading'] or '—'} |" 237 | ) 238 | lines.append("") 239 | lines.append(f"**Selected:** `{selected_rel.as_posix()}` → {tag_name}") 240 | with open(summary_path, "a", encoding="utf-8") as summary_file: 241 | summary_file.write("\n".join(lines) + "\n") 242 | 243 | with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: 244 | fh.write(f"tag={tag_name}\n") 245 | fh.write(f"release_title={release_title}\n") 246 | fh.write(f"notes_file={selected_rel.as_posix()}\n") 247 | PY 248 | - name: Create or verify tag 249 | id: tag 250 | env: 251 | SOURCE_REF: ${{ inputs.source_ref }} 252 | CREATE_TAG: ${{ inputs.create_tag }} 253 | shell: bash 254 | run: | 255 | set -euo pipefail 256 | TAG_NAME="${{ steps.metadata.outputs.tag }}" 257 | if [ -z "$TAG_NAME" ]; then 258 | TAG_NAME="${{ steps.version.outputs.tagged }}" 259 | fi 260 | if [ -z "$TAG_NAME" ]; then 261 | echo "Tag input is required." >&2 262 | exit 1 263 | fi 264 | git fetch origin --prune --tags 265 | if ! git ls-remote --exit-code --heads origin "$SOURCE_REF" >/dev/null; then 266 | echo "Source ref '$SOURCE_REF' not found on origin." >&2 267 | exit 1 268 | fi 269 | if git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null; then 270 | echo "Tag '$TAG_NAME' already exists on origin." >&2 271 | if [ "${CREATE_TAG,,}" != "true" ]; then 272 | echo "create_tag is not true; leaving tag untouched." >&2 273 | else 274 | git fetch origin "$SOURCE_REF" 275 | git checkout --detach "origin/$SOURCE_REF" 276 | git tag -f "$TAG_NAME" 277 | git push origin "refs/tags/$TAG_NAME" --force 278 | fi 279 | else 280 | git fetch origin "$SOURCE_REF" 281 | git checkout --detach "origin/$SOURCE_REF" 282 | git tag "$TAG_NAME" 283 | git push origin "refs/tags/$TAG_NAME" 284 | fi 285 | echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" 286 | 287 | cache-linux: 288 | name: Cache deps (Linux) 289 | runs-on: ubuntu-latest 290 | needs: prepare 291 | steps: 292 | - uses: actions/checkout@v4 293 | - name: Determine version from version.py 294 | id: version 295 | run: | 296 | python <<'PY' 297 | import os, re, runpy 298 | data = runpy.run_path('version.py') 299 | raw = str(data.get('APP_VERSION', '')).strip() 300 | norm = re.sub(r"[^0-9.]", ".", raw) 301 | norm = re.sub(r"\.+", ".", norm).strip(".") or "0.0.0" 302 | with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: 303 | fh.write(f'normalized={norm}\n') 304 | PY 305 | - uses: actions/setup-python@v5 306 | with: 307 | python-version: '3.12' 308 | - name: Cache pip downloads 309 | uses: actions/cache@v4 310 | with: 311 | path: ~/.cache/pip 312 | key: ${{ runner.os }}-pip-3.12-${{ hashFiles('requirements.txt') }} 313 | restore-keys: | 314 | ${{ runner.os }}-pip-3.12- 315 | ${{ runner.os }}-pip- 316 | - name: Cache Nuitka build cache 317 | uses: actions/cache@v4 318 | with: 319 | path: .nuitka-cache 320 | key: nuitka-linux-${{ steps.version.outputs.normalized }} 321 | restore-keys: | 322 | nuitka-linux- 323 | - name: Cache linuxdeploy toolchain 324 | uses: actions/cache@v4 325 | with: 326 | path: build/linuxdeploy-tools 327 | key: linuxdeploy-${{ runner.os }} 328 | - name: Pre-download dependencies 329 | run: | 330 | python -m pip install -U pip 331 | python -m pip install -r requirements.txt 332 | python -m pip install -U nuitka pyside6 333 | TOOLS_DIR=build/linuxdeploy-tools 334 | mkdir -p "$TOOLS_DIR" 335 | fetch() { url="$1"; dest="$2"; if [ ! -f "$dest" ]; then wget -q "$url" -O "$dest"; fi; } 336 | fetch "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-x86_64.AppImage" 337 | fetch "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-qt-x86_64.AppImage" 338 | fetch "https://github.com/niess/linuxdeploy-plugin-python/releases/download/continuous/linuxdeploy-plugin-python-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-python-x86_64.AppImage" 339 | fetch "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-appimage-x86_64.AppImage" 340 | 341 | cache-windows: 342 | name: Cache deps (Windows) 343 | runs-on: windows-latest 344 | needs: prepare 345 | steps: 346 | - uses: actions/checkout@v4 347 | - uses: actions/setup-python@v5 348 | with: 349 | python-version: '3.11' 350 | - name: Cache pip downloads 351 | uses: actions/cache@v4 352 | with: 353 | path: ~\AppData\Local\pip\Cache 354 | key: ${{ runner.os }}-pip-3.11-${{ hashFiles('requirements.txt') }} 355 | restore-keys: | 356 | ${{ runner.os }}-pip-3.11- 357 | ${{ runner.os }}-pip- 358 | - name: Pre-download dependencies 359 | run: | 360 | python -m pip install -U pip 361 | python -m pip install -r requirements.txt 362 | python -m pip install --upgrade nuitka 363 | 364 | build-windows: 365 | needs: 366 | - prepare 367 | - cache-windows 368 | uses: ./.github/workflows/windows-build.yml 369 | with: 370 | ref: ${{ needs.prepare.outputs.tag }} 371 | secrets: inherit 372 | 373 | build-linux: 374 | needs: 375 | - prepare 376 | - cache-linux 377 | uses: ./.github/workflows/linux-build.yml 378 | with: 379 | ref: ${{ needs.prepare.outputs.tag }} 380 | secrets: inherit 381 | 382 | release: 383 | name: Publish stable release 384 | runs-on: ubuntu-latest 385 | environment: 386 | name: stable-release 387 | needs: 388 | - prepare 389 | - build-windows 390 | - build-linux 391 | steps: 392 | - name: Checkout repository 393 | uses: actions/checkout@v4 394 | with: 395 | ref: ${{ needs.prepare.outputs.tag }} 396 | fetch-depth: 0 397 | - name: Download Windows artifact 398 | uses: actions/download-artifact@v4 399 | with: 400 | name: ${{ needs.build-windows.outputs.artifact_name }} 401 | path: artifacts/windows 402 | - name: Download Linux artifact 403 | uses: actions/download-artifact@v4 404 | with: 405 | name: ${{ needs.build-linux.outputs.artifact_name }} 406 | path: artifacts/linux 407 | - name: Inspect downloaded artifacts 408 | run: | 409 | set -euo pipefail 410 | echo "Contents of artifacts directory:" 411 | find artifacts -maxdepth 4 -print 412 | - name: Extract archived artifacts (if any) 413 | run: | 414 | set -euo pipefail 415 | shopt -s nullglob 416 | for dir in artifacts/windows artifacts/linux; do 417 | if [ -d "$dir" ]; then 418 | for archive in "${dir}"/*.zip "${dir}"/*.tar "${dir}"/*.tar.gz "${dir}"/*.tgz; do 419 | [ -e "$archive" ] || continue 420 | echo "Extracting $archive" 421 | case "$archive" in 422 | *.tar.gz|*.tgz) tar -xzf "$archive" -C "$dir" ;; 423 | *.tar) tar -xf "$archive" -C "$dir" ;; 424 | *.zip) unzip -q "$archive" -d "$dir" ;; 425 | esac 426 | rm "$archive" 427 | done 428 | fi 429 | done 430 | - name: Prepare release notes 431 | env: 432 | TAG: ${{ needs.prepare.outputs.tag }} 433 | WINDOWS_HASH: ${{ needs.build-windows.outputs.hash }} 434 | WINDOWS_VT_URL: ${{ needs.build-windows.outputs.vt_url }} 435 | LINUX_HASH: ${{ needs.build-linux.outputs.hash }} 436 | LINUX_VT_URL: ${{ needs.build-linux.outputs.vt_url }} 437 | REPO: ${{ github.repository }} 438 | RELEASE_NOTES_SOURCE: ${{ needs.prepare.outputs.notes_file }} 439 | RELEASE_TITLE: ${{ needs.prepare.outputs.release_title }} 440 | run: | 441 | python <<'PY' 442 | from pathlib import Path 443 | import os 444 | 445 | repo = os.environ["REPO"] 446 | repo_root = Path.cwd() 447 | tag = os.environ["TAG"] 448 | release_title = os.environ["RELEASE_TITLE"] 449 | source_path = Path(os.environ["RELEASE_NOTES_SOURCE"]) 450 | if not source_path.is_absolute(): 451 | source_path = repo_root / source_path 452 | source_path = source_path.resolve() 453 | 454 | try: 455 | source_path.relative_to(repo_root) 456 | except ValueError as exc: 457 | raise SystemExit("Release notes must live within the repository.") from exc 458 | 459 | if not source_path.exists(): 460 | raise SystemExit(f"Source release notes not found: {source_path}") 461 | 462 | windows_vt = os.environ.get("WINDOWS_VT_URL", "").strip() or "https://www.virustotal.com/" 463 | linux_vt = os.environ.get("LINUX_VT_URL", "").strip() or "https://www.virustotal.com/" 464 | windows_hash = os.environ.get("WINDOWS_HASH", "").strip() or "None" 465 | linux_hash = os.environ.get("LINUX_HASH", "").strip() or "None" 466 | asset_windows = "PatchOpsIII.exe" 467 | asset_linux = "PatchOpsIII.AppImage" 468 | base_url = f"https://github.com/{repo}/releases/download/{tag}" 469 | windows_download = f"{base_url}/{asset_windows}" 470 | linux_download = f"{base_url}/{asset_linux}" 471 | 472 | text = source_path.read_text(encoding="utf-8").splitlines() 473 | heading_updated = False 474 | heading_line = f"# {release_title} Release Notes" 475 | for idx, line in enumerate(text): 476 | if line.strip().startswith("#"): 477 | text[idx] = heading_line 478 | heading_updated = True 479 | break 480 | if not heading_updated: 481 | text.insert(0, heading_line) 482 | text.insert(1, "") 483 | 484 | replacements = { 485 | "{{WINDOWS_VT_URL}}": windows_vt, 486 | "{{LINUX_VT_URL}}": linux_vt, 487 | "{{WINDOWS_SHA256}}": windows_hash, 488 | "{{LINUX_SHA256}}": linux_hash, 489 | "{{WINDOWS_DOWNLOAD_URL}}": windows_download, 490 | "{{LINUX_DOWNLOAD_URL}}": linux_download, 491 | } 492 | 493 | final_text = "\n".join(text) 494 | for placeholder, value in replacements.items(): 495 | final_text = final_text.replace(placeholder, value) 496 | 497 | if not final_text.endswith("\n"): 498 | final_text += "\n" 499 | 500 | Path("release-notes.md").write_text(final_text, encoding="utf-8") 501 | PY 502 | - name: Organize release assets 503 | env: 504 | VERSION: ${{ needs.prepare.outputs.tag }} 505 | run: | 506 | set -euo pipefail 507 | mkdir -p release 508 | echo "Collecting release binaries" 509 | win_exec=$(find artifacts/windows -type f -name 'PatchOpsIII.exe' -print -quit) 510 | if [ -z "$win_exec" ]; then 511 | echo "Windows executable not found under artifacts/windows" >&2 512 | find artifacts/windows -type f -print >&2 513 | exit 1 514 | fi 515 | cp "$win_exec" "release/PatchOpsIII.exe" 516 | if [ -f "$(dirname "$win_exec")/PatchOpsIII.sig" ]; then 517 | cp "$(dirname "$win_exec")/PatchOpsIII.sig" "release/PatchOpsIII.sig" 518 | fi 519 | linux_exec=$(find artifacts/linux -type f -name 'PatchOpsIII.AppImage' -print -quit) 520 | if [ -z "$linux_exec" ]; then 521 | echo "Linux binary not found under artifacts/linux" >&2 522 | find artifacts/linux -type f -print >&2 523 | exit 1 524 | fi 525 | cp "$linux_exec" "release/PatchOpsIII.AppImage" 526 | chmod +x "release/PatchOpsIII.AppImage" 527 | zsync_file="$(dirname "$linux_exec")/PatchOpsIII.AppImage.zsync" 528 | if [ -f "$zsync_file" ]; then 529 | cp "$zsync_file" "release/PatchOpsIII.AppImage.zsync" 530 | fi 531 | if [ -f "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" ]; then 532 | cp "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" "release/PatchOpsIII.AppImage.sig" 533 | fi 534 | - name: Publish GitHub release 535 | uses: softprops/action-gh-release@v2 536 | with: 537 | tag_name: ${{ needs.prepare.outputs.tag }} 538 | name: ${{ needs.prepare.outputs.release_title }} 539 | prerelease: false 540 | body_path: release-notes.md 541 | files: | 542 | release/* 543 | env: 544 | GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} 545 | -------------------------------------------------------------------------------- /.github/workflows/release-beta.yml: -------------------------------------------------------------------------------- 1 | name: Release Beta 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release_notes_file: 7 | description: | 8 | Release notes file name (leave blank or set to "latest" to auto-select the newest notes). 9 | Provide a file that lives under `Release Notes/`. 10 | required: false 11 | type: string 12 | default: latest 13 | source_ref: 14 | description: Source branch or tag to tag from 15 | type: string 16 | default: main 17 | create_tag: 18 | description: Create or update the tag before building (true/false) 19 | type: string 20 | default: "true" 21 | 22 | permissions: 23 | contents: write 24 | id-token: write 25 | 26 | jobs: 27 | prepare: 28 | name: Ensure tag 29 | runs-on: ubuntu-latest 30 | outputs: 31 | tag: ${{ steps.tag.outputs.tag }} 32 | base_version: ${{ steps.metadata.outputs.base_version }} 33 | release_title: ${{ steps.metadata.outputs.release_title }} 34 | notes_file: ${{ steps.metadata.outputs.notes_file }} 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | - name: Determine version from version.py 41 | id: version 42 | run: | 43 | python <<'PY' 44 | import os 45 | import re 46 | import runpy 47 | 48 | data = runpy.run_path('version.py') 49 | raw_version = str(data.get('APP_VERSION', '')).strip() 50 | if not raw_version: 51 | raise SystemExit('APP_VERSION is empty in version.py') 52 | 53 | tagged = raw_version if raw_version.lower().startswith('v') else f'v{raw_version}' 54 | normalized = re.sub(r"[^0-9.]", ".", raw_version) 55 | normalized = re.sub(r"\.+", ".", normalized).strip(".") or "0.0.0" 56 | 57 | with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: 58 | fh.write(f'raw={raw_version}\n') 59 | fh.write(f'tagged={tagged}\n') 60 | fh.write(f'normalized={normalized}\n') 61 | 62 | with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as fh: 63 | fh.write(f'PATCHOPSIII_VERSION={raw_version}\n') 64 | PY 65 | - name: Determine release metadata 66 | id: metadata 67 | env: 68 | RELEASE_NOTES_FILE: ${{ inputs.release_notes_file }} 69 | PROJECT_VERSION: ${{ steps.version.outputs.raw }} 70 | run: | 71 | python <<'PY' 72 | import os 73 | import re 74 | from pathlib import Path 75 | 76 | choice = os.environ.get("RELEASE_NOTES_FILE", "").strip() 77 | 78 | repo_root = Path.cwd() 79 | notes_dir = repo_root / "Release Notes" 80 | if not notes_dir.is_dir(): 81 | raise SystemExit("Release Notes directory not found in the repository.") 82 | 83 | candidates = [ 84 | path.relative_to(repo_root) 85 | for path in notes_dir.rglob("*.md") 86 | if path.is_file() and path.name.lower() != "template.md" 87 | ] 88 | 89 | if not candidates: 90 | raise SystemExit("No release notes files found under 'Release Notes/'.") 91 | 92 | metadata = {} 93 | version_regex = re.compile(r"\bv?(?P[0-9][\w.\-]*)", re.IGNORECASE) 94 | 95 | def load_metadata(rel_path: Path): 96 | abs_path = repo_root / rel_path 97 | heading = None 98 | with abs_path.open("r", encoding="utf-8") as handle: 99 | for raw_line in handle: 100 | stripped = raw_line.strip() 101 | if stripped.startswith("#"): 102 | heading = stripped.lstrip("#").strip() 103 | break 104 | 105 | version_token = None 106 | for target in (heading or "", rel_path.stem): 107 | if not target: 108 | continue 109 | match = version_regex.search(target) 110 | if match: 111 | version_token = match.group(0) 112 | if not version_token.lower().startswith("v"): 113 | version_token = f"v{match.group('body')}" 114 | break 115 | 116 | metadata[rel_path] = {"heading": heading, "version": version_token} 117 | return metadata[rel_path] 118 | 119 | for candidate in candidates: 120 | load_metadata(candidate) 121 | 122 | def sort_key(rel_path: Path): 123 | info = metadata[rel_path] 124 | token = (info["version"] or "").lower() 125 | if token.startswith("v"): 126 | token = token[1:] 127 | base_part, _, suffix_part = token.partition("-") 128 | number_values = tuple(int(part) for part in re.findall(r"\d+", base_part)) or (0,) 129 | stability = 1 if not suffix_part else 0 130 | return (number_values, stability, token, rel_path.name.lower()) 131 | 132 | normalized_choice = choice.casefold() 133 | selected_rel = None 134 | if normalized_choice and normalized_choice != "latest": 135 | for candidate in candidates: 136 | candidate_names = { 137 | candidate.name.casefold(), 138 | candidate.stem.casefold(), 139 | candidate.as_posix().casefold(), 140 | } 141 | candidate_version = metadata[candidate]["version"] 142 | if candidate_version: 143 | version_key = candidate_version.casefold() 144 | candidate_names.add(version_key) 145 | candidate_names.add(version_key.removeprefix("v")) 146 | heading = metadata[candidate]["heading"] 147 | if heading: 148 | candidate_names.add(heading.casefold()) 149 | candidate_names.add(heading.replace("Release Notes", "").strip().casefold()) 150 | if normalized_choice in candidate_names: 151 | selected_rel = candidate 152 | break 153 | 154 | if selected_rel is None: 155 | raw_path = Path(choice) 156 | candidate_paths = [] 157 | if raw_path.is_absolute(): 158 | candidate_paths.append(raw_path) 159 | else: 160 | candidate_paths.append(repo_root / raw_path) 161 | candidate_paths.append(notes_dir / raw_path) 162 | if raw_path.suffix == "": 163 | candidate_paths.append((repo_root / raw_path).with_suffix(".md")) 164 | candidate_paths.append((notes_dir / raw_path).with_suffix(".md")) 165 | 166 | selected_abs = None 167 | seen = set() 168 | for possible in candidate_paths: 169 | if possible is None: 170 | continue 171 | resolved = possible.resolve() 172 | key = resolved.as_posix() 173 | if key in seen: 174 | continue 175 | seen.add(key) 176 | if resolved.is_file(): 177 | selected_abs = resolved 178 | break 179 | 180 | if selected_abs is None: 181 | available = ", ".join(sorted(path.name for path in candidates)) 182 | raise SystemExit( 183 | f"Release notes file not found for selection '{choice}'. Available files: {available}" 184 | ) 185 | 186 | try: 187 | selected_abs.relative_to(notes_dir.resolve()) 188 | except ValueError as exc: 189 | raise SystemExit("Release notes must live under 'Release Notes/'.") from exc 190 | 191 | if selected_abs.name.lower() == "template.md": 192 | raise SystemExit("Template release notes cannot be used for beta releases.") 193 | 194 | selected_rel = selected_abs.relative_to(repo_root.resolve()) 195 | load_metadata(selected_rel) 196 | 197 | if selected_rel is None: 198 | selected_rel = max(candidates, key=sort_key) 199 | 200 | selected_abs = repo_root / selected_rel 201 | info = metadata[selected_rel] 202 | version_token = os.environ.get("PROJECT_VERSION", "").strip() 203 | if not version_token: 204 | raise SystemExit("PROJECT_VERSION was not provided from version.py.") 205 | 206 | if not version_token.lower().startswith("v"): 207 | version_token = f"v{version_token}" 208 | 209 | base_version = re.sub(r"(?i)-beta$", "", version_token) 210 | tag_name = f"{base_version}-beta" 211 | notes_version = (info.get("version") or "").strip() 212 | 213 | if not notes_version: 214 | raise SystemExit( 215 | f"Selected release notes '{selected_rel.as_posix()}' do not contain a version heading." 216 | ) 217 | 218 | if notes_version.lower() != version_token.lower(): 219 | raise SystemExit( 220 | f"Release notes version '{notes_version}' does not match PROJECT_VERSION '{version_token}'. " 221 | "Update version.py or choose matching release notes." 222 | ) 223 | release_title = f"PatchOpsIII {tag_name}" 224 | 225 | print(f"Selected release notes: {selected_rel.as_posix()}") 226 | 227 | summary_path = os.environ.get("GITHUB_STEP_SUMMARY") 228 | if summary_path: 229 | lines = [ 230 | "# Available release notes", 231 | "", 232 | "| File | Version | Heading |", 233 | "| --- | --- | --- |", 234 | ] 235 | for candidate in sorted(candidates, key=sort_key, reverse=True): 236 | candidate_info = metadata[candidate] 237 | lines.append( 238 | f"| `{candidate.as_posix()}` | {candidate_info['version'] or '—'} | {candidate_info['heading'] or '—'} |" 239 | ) 240 | lines.append("") 241 | lines.append(f"**Selected:** `{selected_rel.as_posix()}` → {tag_name}") 242 | with open(summary_path, "a", encoding="utf-8") as summary_file: 243 | summary_file.write("\n".join(lines) + "\n") 244 | 245 | with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: 246 | fh.write(f"tag={tag_name}\n") 247 | fh.write(f"base_version={base_version}\n") 248 | fh.write(f"release_title={release_title}\n") 249 | fh.write(f"notes_file={selected_rel.as_posix()}\n") 250 | PY 251 | - name: Create or verify tag 252 | id: tag 253 | env: 254 | SOURCE_REF: ${{ inputs.source_ref }} 255 | CREATE_TAG: ${{ inputs.create_tag }} 256 | shell: bash 257 | run: | 258 | set -euo pipefail 259 | TAG_NAME="${{ steps.metadata.outputs.tag }}" 260 | if [ -z "$TAG_NAME" ]; then 261 | TAG_NAME="${{ steps.version.outputs.tagged }}" 262 | fi 263 | if [ -z "$TAG_NAME" ]; then 264 | echo "Tag input is required." >&2 265 | exit 1 266 | fi 267 | git fetch origin --prune --tags 268 | if ! git ls-remote --exit-code --heads origin "$SOURCE_REF" >/dev/null; then 269 | echo "Source ref '$SOURCE_REF' not found on origin." >&2 270 | exit 1 271 | fi 272 | if git ls-remote --exit-code --tags origin "refs/tags/$TAG_NAME" >/dev/null; then 273 | echo "Tag '$TAG_NAME' already exists on origin." >&2 274 | if [ "${CREATE_TAG,,}" != "true" ]; then 275 | echo "create_tag is not true; leaving tag untouched." >&2 276 | else 277 | git fetch origin "$SOURCE_REF" 278 | git checkout --detach "origin/$SOURCE_REF" 279 | git tag -f "$TAG_NAME" 280 | git push origin "refs/tags/$TAG_NAME" --force 281 | fi 282 | else 283 | git fetch origin "$SOURCE_REF" 284 | git checkout --detach "origin/$SOURCE_REF" 285 | git tag "$TAG_NAME" 286 | git push origin "refs/tags/$TAG_NAME" 287 | fi 288 | echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" 289 | - name: Configure git 290 | run: | 291 | git config user.name "${{ github.actor }}" 292 | git config user.email "${{ github.actor }}@users.noreply.github.com" 293 | 294 | cache-linux: 295 | name: Cache deps (Linux) 296 | runs-on: ubuntu-latest 297 | needs: prepare 298 | steps: 299 | - uses: actions/checkout@v4 300 | - name: Determine version from version.py 301 | id: version 302 | run: | 303 | python <<'PY' 304 | import os, re, runpy 305 | data = runpy.run_path('version.py') 306 | raw = str(data.get('APP_VERSION', '')).strip() 307 | norm = re.sub(r"[^0-9.]", ".", raw) 308 | norm = re.sub(r"\.+", ".", norm).strip(".") or "0.0.0" 309 | with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: 310 | fh.write(f'normalized={norm}\n') 311 | PY 312 | - uses: actions/setup-python@v5 313 | with: 314 | python-version: '3.12' 315 | - name: Cache pip downloads 316 | uses: actions/cache@v4 317 | with: 318 | path: ~/.cache/pip 319 | key: ${{ runner.os }}-pip-3.12-${{ hashFiles('requirements.txt') }} 320 | restore-keys: | 321 | ${{ runner.os }}-pip-3.12- 322 | ${{ runner.os }}-pip- 323 | - name: Cache Nuitka build cache 324 | uses: actions/cache@v4 325 | with: 326 | path: .nuitka-cache 327 | key: nuitka-linux-${{ steps.version.outputs.normalized }} 328 | restore-keys: | 329 | nuitka-linux- 330 | - name: Cache linuxdeploy toolchain 331 | uses: actions/cache@v4 332 | with: 333 | path: build/linuxdeploy-tools 334 | key: linuxdeploy-${{ runner.os }} 335 | - name: Pre-download dependencies 336 | run: | 337 | python -m pip install -U pip 338 | python -m pip install -r requirements.txt 339 | python -m pip install -U nuitka pyside6 340 | TOOLS_DIR=build/linuxdeploy-tools 341 | mkdir -p "$TOOLS_DIR" 342 | fetch() { url="$1"; dest="$2"; if [ ! -f "$dest" ]; then wget -q "$url" -O "$dest"; fi; } 343 | fetch "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-x86_64.AppImage" 344 | fetch "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-qt-x86_64.AppImage" 345 | fetch "https://github.com/niess/linuxdeploy-plugin-python/releases/download/continuous/linuxdeploy-plugin-python-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-python-x86_64.AppImage" 346 | fetch "https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage" "$TOOLS_DIR/linuxdeploy-plugin-appimage-x86_64.AppImage" 347 | 348 | cache-windows: 349 | name: Cache deps (Windows) 350 | runs-on: windows-latest 351 | needs: prepare 352 | steps: 353 | - uses: actions/checkout@v4 354 | - uses: actions/setup-python@v5 355 | with: 356 | python-version: '3.11' 357 | - name: Cache pip downloads 358 | uses: actions/cache@v4 359 | with: 360 | path: ~\AppData\Local\pip\Cache 361 | key: ${{ runner.os }}-pip-3.11-${{ hashFiles('requirements.txt') }} 362 | restore-keys: | 363 | ${{ runner.os }}-pip-3.11- 364 | ${{ runner.os }}-pip- 365 | - name: Pre-download dependencies 366 | run: | 367 | python -m pip install -U pip 368 | python -m pip install -r requirements.txt 369 | python -m pip install --upgrade nuitka 370 | build-windows: 371 | needs: 372 | - prepare 373 | - cache-windows 374 | uses: ./.github/workflows/windows-build.yml 375 | with: 376 | ref: ${{ needs.prepare.outputs.tag }} 377 | secrets: inherit 378 | 379 | build-linux: 380 | needs: 381 | - prepare 382 | - cache-linux 383 | uses: ./.github/workflows/linux-build.yml 384 | with: 385 | ref: ${{ needs.prepare.outputs.tag }} 386 | secrets: inherit 387 | 388 | release: 389 | name: Publish beta release 390 | runs-on: ubuntu-latest 391 | environment: 392 | name: beta-release 393 | needs: 394 | - prepare 395 | - build-windows 396 | - build-linux 397 | steps: 398 | - name: Checkout repository 399 | uses: actions/checkout@v4 400 | with: 401 | ref: ${{ needs.prepare.outputs.tag }} 402 | fetch-depth: 0 403 | - name: Download Windows artifact 404 | uses: actions/download-artifact@v4 405 | with: 406 | name: ${{ needs.build-windows.outputs.artifact_name }} 407 | path: artifacts/windows 408 | - name: Download Linux artifact 409 | uses: actions/download-artifact@v4 410 | with: 411 | name: ${{ needs.build-linux.outputs.artifact_name }} 412 | path: artifacts/linux 413 | - name: Inspect downloaded artifacts 414 | run: | 415 | set -euo pipefail 416 | echo "Contents of artifacts directory:" 417 | find artifacts -maxdepth 4 -print 418 | - name: Extract archived artifacts (if any) 419 | run: | 420 | set -euo pipefail 421 | shopt -s nullglob 422 | for dir in artifacts/windows artifacts/linux; do 423 | if [ -d "$dir" ]; then 424 | for archive in "${dir}"/*.zip "${dir}"/*.tar "${dir}"/*.tar.gz "${dir}"/*.tgz; do 425 | [ -e "$archive" ] || continue 426 | echo "Extracting $archive" 427 | case "$archive" in 428 | *.tar.gz|*.tgz) tar -xzf "$archive" -C "$dir" ;; 429 | *.tar) tar -xf "$archive" -C "$dir" ;; 430 | *.zip) unzip -q "$archive" -d "$dir" ;; 431 | esac 432 | rm "$archive" 433 | done 434 | fi 435 | done 436 | - name: Prepare release notes 437 | env: 438 | TAG: ${{ needs.prepare.outputs.tag }} 439 | WINDOWS_HASH: ${{ needs.build-windows.outputs.hash }} 440 | WINDOWS_VT_URL: ${{ needs.build-windows.outputs.vt_url }} 441 | LINUX_HASH: ${{ needs.build-linux.outputs.hash }} 442 | LINUX_VT_URL: ${{ needs.build-linux.outputs.vt_url }} 443 | REPO: ${{ github.repository }} 444 | RELEASE_NOTES_SOURCE: ${{ needs.prepare.outputs.notes_file }} 445 | RELEASE_TITLE: ${{ needs.prepare.outputs.release_title }} 446 | run: | 447 | python <<'PY' 448 | from pathlib import Path 449 | import os 450 | 451 | repo = os.environ["REPO"] 452 | repo_root = Path.cwd() 453 | tag = os.environ["TAG"] 454 | release_title = os.environ["RELEASE_TITLE"] 455 | source_path = Path(os.environ["RELEASE_NOTES_SOURCE"]) 456 | if not source_path.is_absolute(): 457 | source_path = repo_root / source_path 458 | source_path = source_path.resolve() 459 | 460 | try: 461 | source_path.relative_to(repo_root) 462 | except ValueError as exc: 463 | raise SystemExit("Release notes must live within the repository.") from exc 464 | 465 | if not source_path.exists(): 466 | raise SystemExit(f"Source release notes not found: {source_path}") 467 | 468 | windows_vt = os.environ.get("WINDOWS_VT_URL", "").strip() or "https://www.virustotal.com/" 469 | linux_vt = os.environ.get("LINUX_VT_URL", "").strip() or "https://www.virustotal.com/" 470 | windows_hash = os.environ.get("WINDOWS_HASH", "").strip() or "None" 471 | linux_hash = os.environ.get("LINUX_HASH", "").strip() or "None" 472 | asset_windows = "PatchOpsIII-Beta.exe" 473 | asset_linux = "PatchOpsIII-Beta.AppImage" 474 | base_url = f"https://github.com/{repo}/releases/download/{tag}" 475 | windows_download = f"{base_url}/{asset_windows}" 476 | linux_download = f"{base_url}/{asset_linux}" 477 | 478 | text = source_path.read_text(encoding="utf-8").splitlines() 479 | heading_updated = False 480 | heading_line = f"# {release_title} Release Notes" 481 | for idx, line in enumerate(text): 482 | if line.strip().startswith("#"): 483 | text[idx] = heading_line 484 | heading_updated = True 485 | break 486 | if not heading_updated: 487 | text.insert(0, heading_line) 488 | text.insert(1, "") 489 | 490 | replacements = { 491 | "{{WINDOWS_VT_URL}}": windows_vt, 492 | "{{LINUX_VT_URL}}": linux_vt, 493 | "{{WINDOWS_SHA256}}": windows_hash, 494 | "{{LINUX_SHA256}}": linux_hash, 495 | "{{WINDOWS_DOWNLOAD_URL}}": windows_download, 496 | "{{LINUX_DOWNLOAD_URL}}": linux_download, 497 | } 498 | 499 | final_text = "\n".join(text) 500 | for placeholder, value in replacements.items(): 501 | final_text = final_text.replace(placeholder, value) 502 | 503 | if not final_text.endswith("\n"): 504 | final_text += "\n" 505 | 506 | Path("release-notes.md").write_text(final_text, encoding="utf-8") 507 | PY 508 | - name: Organize release assets 509 | env: 510 | VERSION: ${{ needs.prepare.outputs.tag }} 511 | run: | 512 | set -euo pipefail 513 | mkdir -p release 514 | echo "Collecting release binaries" 515 | win_exec=$(find artifacts/windows -type f -name 'PatchOpsIII.exe' -print -quit) 516 | if [ -z "$win_exec" ]; then 517 | echo "Windows executable not found under artifacts/windows" >&2 518 | find artifacts/windows -type f -print >&2 519 | exit 1 520 | fi 521 | cp "$win_exec" "release/PatchOpsIII-Beta.exe" 522 | if [ -f "$(dirname "$win_exec")/PatchOpsIII.sig" ]; then 523 | cp "$(dirname "$win_exec")/PatchOpsIII.sig" "release/PatchOpsIII-Beta.sig" 524 | fi 525 | linux_exec=$(find artifacts/linux -type f -name 'PatchOpsIII.AppImage' -print -quit) 526 | if [ -z "$linux_exec" ]; then 527 | echo "Linux binary not found under artifacts/linux" >&2 528 | find artifacts/linux -type f -print >&2 529 | exit 1 530 | fi 531 | cp "$linux_exec" "release/PatchOpsIII-Beta.AppImage" 532 | chmod +x "release/PatchOpsIII-Beta.AppImage" 533 | zsync_file="$(dirname "$linux_exec")/PatchOpsIII.AppImage.zsync" 534 | if [ -f "$zsync_file" ]; then 535 | cp "$zsync_file" "release/PatchOpsIII-Beta.AppImage.zsync" 536 | fi 537 | if [ -f "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" ]; then 538 | cp "$(dirname "$linux_exec")/PatchOpsIII.AppImage.sig" "release/PatchOpsIII-Beta.AppImage.sig" 539 | fi 540 | - name: Publish GitHub release 541 | uses: softprops/action-gh-release@v2 542 | with: 543 | tag_name: ${{ needs.prepare.outputs.tag }} 544 | name: ${{ needs.prepare.outputs.release_title }} 545 | prerelease: true 546 | body_path: release-notes.md 547 | files: | 548 | release/* 549 | env: 550 | GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} 551 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import re 4 | import shutil 5 | import subprocess 6 | import sys 7 | import time 8 | import vdf 9 | import platform 10 | 11 | DEFAULT_LOG_FILENAME = "PatchOpsIII.log" 12 | LAUNCH_COUNTER_FILENAME = "launch_counter.txt" 13 | 14 | def get_app_data_dir(): 15 | system = platform.system() 16 | home = os.path.expanduser("~") 17 | 18 | if system == "Windows": 19 | base = os.environ.get("APPDATA") or os.path.join(home, "AppData", "Roaming") 20 | elif system == "Darwin": 21 | base = os.path.join(home, "Library", "Application Support") 22 | else: 23 | base = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share") 24 | 25 | return os.path.join(base, "PatchOpsIII") 26 | 27 | def _resolve_log_path(log_file): 28 | if not log_file: 29 | log_file = DEFAULT_LOG_FILENAME 30 | 31 | if os.path.isabs(log_file): 32 | return log_file 33 | 34 | filename = os.path.basename(log_file) or DEFAULT_LOG_FILENAME 35 | return os.path.join(get_app_data_dir(), filename) 36 | 37 | def write_log(message, category="Info", log_widget=None, log_file=DEFAULT_LOG_FILENAME): 38 | timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 39 | full_message = f"{timestamp} - {category}: {message}" 40 | if log_widget: 41 | # Define colors based on category 42 | if category == "Info": 43 | color = "white" 44 | elif category == "Error": 45 | color = "red" 46 | elif category == "Warning": 47 | color = "yellow" 48 | elif category == "Success": 49 | color = "green" 50 | else: 51 | color = "blue" 52 | html_message = f'{full_message}' 53 | handler = getattr(log_widget, "handle_write_log", None) 54 | if callable(handler): 55 | handler(full_message=full_message, category=category, html_message=html_message, plain_message=message) 56 | else: 57 | log_widget.append(html_message) 58 | 59 | log_path = _resolve_log_path(log_file) 60 | try: 61 | directory = os.path.dirname(log_path) 62 | if directory: 63 | os.makedirs(directory, exist_ok=True) 64 | with open(log_path, "a", encoding="utf-8") as f: 65 | f.write(full_message + "\n") 66 | except Exception as exc: 67 | try: 68 | print(f"Failed to write log entry to {log_path}: {exc}", file=sys.stderr) 69 | except Exception: 70 | pass 71 | 72 | 73 | def get_log_file_path(log_file=DEFAULT_LOG_FILENAME): 74 | """Return the resolved path for the given log file.""" 75 | return _resolve_log_path(log_file) 76 | 77 | 78 | def _launch_counter_path(): 79 | return os.path.join(get_app_data_dir(), LAUNCH_COUNTER_FILENAME) 80 | 81 | 82 | def _read_launch_count(): 83 | try: 84 | with open(_launch_counter_path(), "r", encoding="utf-8") as f: 85 | value = f.read().strip() 86 | return int(value) if value else 0 87 | except (FileNotFoundError, ValueError): 88 | return 0 89 | except Exception: 90 | return 0 91 | 92 | 93 | def _write_launch_count(count): 94 | try: 95 | os.makedirs(get_app_data_dir(), exist_ok=True) 96 | with open(_launch_counter_path(), "w", encoding="utf-8") as f: 97 | f.write(str(count)) 98 | except Exception: 99 | pass 100 | 101 | 102 | def clear_log_file(log_file=DEFAULT_LOG_FILENAME): 103 | """Truncate the specified log file.""" 104 | path = _resolve_log_path(log_file) 105 | try: 106 | directory = os.path.dirname(path) 107 | if directory: 108 | os.makedirs(directory, exist_ok=True) 109 | with open(path, "w", encoding="utf-8"): 110 | pass 111 | return True 112 | except Exception as exc: 113 | try: 114 | print(f"Failed to clear log file at {path}: {exc}", file=sys.stderr) 115 | except Exception: 116 | pass 117 | return False 118 | 119 | 120 | def manage_log_retention_on_launch(threshold=3): 121 | """Increment launch counter and clear logs every `threshold` launches.""" 122 | if threshold is None or threshold <= 0: 123 | return False 124 | 125 | count = _read_launch_count() + 1 126 | cleared = False 127 | 128 | if count >= threshold: 129 | cleared = clear_log_file() 130 | count = 0 131 | 132 | _write_launch_count(count) 133 | return cleared 134 | 135 | def _find_windows_steam_root(): 136 | candidates = [] 137 | try: 138 | import winreg 139 | registry_targets = [ 140 | (winreg.HKEY_CURRENT_USER, r"Software\\Valve\\Steam", "SteamPath"), 141 | (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\\WOW6432Node\\Valve\\Steam", "InstallPath"), 142 | (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\\Valve\\Steam", "InstallPath"), 143 | ] 144 | for hive, subkey, value in registry_targets: 145 | try: 146 | with winreg.OpenKey(hive, subkey) as key: 147 | location, _ = winreg.QueryValueEx(key, value) 148 | if location: 149 | candidates.append(location) 150 | except (FileNotFoundError, OSError): 151 | continue 152 | except ImportError: 153 | pass 154 | 155 | possible_program_files = [ 156 | os.environ.get("PROGRAMFILES(X86)"), 157 | os.environ.get("PROGRAMFILES"), 158 | ] 159 | for root in possible_program_files: 160 | if root: 161 | candidates.append(os.path.join(root, "Steam")) 162 | 163 | candidates.extend([ 164 | r"C:\\Program Files (x86)\\Steam", 165 | r"C:\\Program Files\\Steam", 166 | ]) 167 | 168 | for candidate in candidates: 169 | if not candidate: 170 | continue 171 | normalized = os.path.normpath(candidate) 172 | exe_path = os.path.join(normalized, "steam.exe") 173 | if os.path.exists(exe_path): 174 | return normalized 175 | return None 176 | def get_steam_paths(): 177 | system = platform.system() 178 | if system == "Windows": 179 | steam_root = _find_windows_steam_root() 180 | if steam_root: 181 | return { 182 | 'userdata': os.path.join(steam_root, "userdata"), 183 | 'steam_exe': os.path.join(steam_root, "steam.exe"), 184 | } 185 | default_base = os.environ.get("PROGRAMFILES(X86)") or os.environ.get("PROGRAMFILES") or r"C:\\Program Files (x86)" 186 | default_root = os.path.join(default_base, "Steam") 187 | return { 188 | 'userdata': os.path.join(default_root, "userdata"), 189 | 'steam_exe': os.path.join(default_root, "steam.exe"), 190 | } 191 | if system == "Linux": 192 | home = os.path.expanduser("~") 193 | return { 194 | 'userdata': os.path.join(home, ".steam/steam/userdata"), 195 | 'steam_exe': "steam", 196 | } 197 | if system == "Darwin": 198 | home = os.path.expanduser("~") 199 | return { 200 | 'userdata': os.path.join(home, "Library/Application Support/Steam/userdata"), 201 | 'steam_exe': "open", 202 | } 203 | return None 204 | steam_paths = get_steam_paths() 205 | steam_userdata_path = None 206 | steam_exe_path = None 207 | if steam_paths: 208 | steam_userdata_path = steam_paths.get('userdata') 209 | steam_exe_path = steam_paths.get('steam_exe') 210 | else: 211 | write_log("Unsupported operating system", "Error", None) 212 | 213 | if platform.system() == "Windows" and steam_exe_path and not os.path.exists(steam_exe_path): 214 | write_log(f"Steam executable not found at {steam_exe_path}. Update your configuration or reinstall Steam.", "Warning", None) 215 | 216 | app_id = "311210" # Black Ops III AppID 217 | 218 | def find_steam_user_id(): 219 | if not steam_userdata_path or not os.path.exists(steam_userdata_path): 220 | write_log("Steam userdata path not found!", "Warning", None) 221 | return None 222 | user_ids = [f for f in os.listdir(steam_userdata_path) if f.isdigit()] 223 | if not user_ids: 224 | write_log("No Steam user ID found!", "Warning", None) 225 | return None 226 | return user_ids[0] 227 | 228 | def get_backup_locations(): 229 | user_backup_dir = os.path.join(get_app_data_dir(), "backups") 230 | module_backup_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups") 231 | locations = [user_backup_dir] 232 | if os.path.normpath(module_backup_dir) != os.path.normpath(user_backup_dir): 233 | locations.append(module_backup_dir) 234 | return locations 235 | 236 | def backup_config_file(config_path, log_widget): 237 | backup_dirs = get_backup_locations() 238 | primary_dir = backup_dirs[0] 239 | try: 240 | os.makedirs(primary_dir, exist_ok=True) 241 | except Exception as e: 242 | write_log(f"Failed to prepare backup directory '{primary_dir}': {e}", "Error", log_widget) 243 | return False 244 | 245 | backup_file_path = os.path.join(primary_dir, "localconfig_backup.vdf") 246 | 247 | try: 248 | shutil.copy2(config_path, backup_file_path) # copy2 preserves metadata 249 | write_log(f"Config backup created at {backup_file_path}", "Success", log_widget) 250 | return True 251 | except Exception as e: 252 | write_log(f"Failed to create backup: {e}", "Error", log_widget) 253 | return False 254 | 255 | def restore_config_file(config_path, log_widget): 256 | for directory in get_backup_locations(): 257 | backup_file_path = os.path.join(directory, "localconfig_backup.vdf") 258 | if not os.path.exists(backup_file_path): 259 | continue 260 | try: 261 | shutil.copy2(backup_file_path, config_path) 262 | write_log("Config restored from backup", "Success", log_widget) 263 | return True 264 | except Exception as e: 265 | write_log(f"Failed to restore backup from {backup_file_path}: {e}", "Error", log_widget) 266 | return False 267 | write_log("No backup file found", "Warning", log_widget) 268 | return False 269 | 270 | def is_steam_running(): 271 | system = platform.system() 272 | try: 273 | if system == "Windows": 274 | result = subprocess.run( 275 | ["tasklist", "/FI", "IMAGENAME eq steam.exe"], 276 | capture_output=True, 277 | text=True, 278 | timeout=5 279 | ) 280 | output = (result.stdout or "").lower() 281 | return "steam.exe" in output 282 | return subprocess.call( 283 | ["pgrep", "-x", "steam"], 284 | stdout=subprocess.DEVNULL, 285 | stderr=subprocess.DEVNULL, 286 | timeout=5 287 | ) == 0 288 | except (subprocess.SubprocessError, OSError): 289 | return False 290 | 291 | def close_steam(log_widget): 292 | system = platform.system() 293 | try: 294 | if system == "Windows": 295 | result = subprocess.run( 296 | ["taskkill", "/F", "/IM", "steam.exe"], 297 | check=False, 298 | timeout=10, 299 | capture_output=True, 300 | text=True 301 | ) 302 | if result.returncode != 0: 303 | if not is_steam_running(): 304 | write_log("Steam was not running.", "Info", log_widget) 305 | else: 306 | write_log(f"Failed to close Steam: {result.stderr.strip() or result.stdout.strip()}", "Error", log_widget) 307 | return 308 | elif system == "Linux": 309 | result = subprocess.run( 310 | ["pkill", "steam"], 311 | check=False, 312 | timeout=10, 313 | stdout=subprocess.DEVNULL, 314 | stderr=subprocess.DEVNULL 315 | ) 316 | if result.returncode == 1: 317 | write_log("Steam was not running.", "Info", log_widget) 318 | elif result.returncode != 0: 319 | write_log(f"Failed to close Steam (return code {result.returncode}).", "Error", log_widget) 320 | return 321 | time.sleep(5) 322 | except subprocess.TimeoutExpired: 323 | write_log("Timed out while attempting to close Steam.", "Warning", log_widget) 324 | except FileNotFoundError as e: 325 | write_log(f"Steam control command not found: {e}", "Error", log_widget) 326 | except subprocess.SubprocessError as e: 327 | write_log(f"Failed to close Steam: {e}", "Error", log_widget) 328 | 329 | def open_steam(log_widget): 330 | system = platform.system() 331 | try: 332 | if system == "Windows": 333 | write_log("Opening Steam...", "Info", log_widget) 334 | if steam_exe_path and os.path.exists(steam_exe_path): 335 | subprocess.Popen([steam_exe_path, "-silent"], 336 | stdout=subprocess.DEVNULL, 337 | stderr=subprocess.DEVNULL) 338 | else: 339 | write_log("Steam executable path not found; attempting to use system URI handler.", "Warning", log_widget) 340 | try: 341 | os.startfile("steam://open/main") # type: ignore[attr-defined] 342 | except AttributeError: 343 | subprocess.Popen(["cmd", "/c", "start", "", "steam://"]) 344 | else: 345 | # Check if Steam is already running 346 | was_running = (subprocess.call(["pgrep", "-x", "steam"], 347 | stdout=subprocess.DEVNULL, timeout=5) == 0) 348 | if was_running: 349 | write_log("Closing Steam...", "Info", log_widget) 350 | subprocess.call(["pkill", "-x", "steam"], 351 | stdout=subprocess.DEVNULL, 352 | stderr=subprocess.DEVNULL, timeout=5) 353 | time.sleep(2) # Allow time for Steam to shut down 354 | 355 | write_log("Setting launch options...", "Info", log_widget) 356 | # (Place here any code that sets your launch options.) 357 | 358 | write_log("Opening Steam...", "Info", log_widget) 359 | subprocess.Popen(["xdg-open", "steam://"], 360 | stdout=subprocess.DEVNULL, 361 | stderr=subprocess.DEVNULL) 362 | # Fallback: if xdg-open fails and 'steam' exists, launch it directly. 363 | if subprocess.call(["which", "steam"], 364 | stdout=subprocess.DEVNULL, timeout=5) == 0: 365 | subprocess.Popen(["steam", "-silent"], 366 | stdout=subprocess.DEVNULL, 367 | stderr=subprocess.DEVNULL) 368 | 369 | def steam_running(): 370 | if system == "Windows": 371 | try: 372 | result = subprocess.run(["tasklist", "/FI", "IMAGENAME eq steam.exe"], 373 | capture_output=True, 374 | text=True, 375 | timeout=5) 376 | except (subprocess.SubprocessError, OSError): 377 | return False 378 | output = (result.stdout or "").lower() 379 | return "steam.exe" in output 380 | return subprocess.call(["pgrep", "-x", "steam"], 381 | stdout=subprocess.DEVNULL, timeout=5) == 0 382 | 383 | max_wait = 15 # maximum total wait time (seconds) 384 | poll_interval = 0.5 # poll every 0.5 seconds 385 | stable_duration = 2 # require Steam to be present for 2 consecutive seconds 386 | elapsed = 0 387 | stable_time = 0 388 | 389 | while elapsed < max_wait: 390 | if steam_running(): 391 | stable_time += poll_interval 392 | if stable_time >= stable_duration: 393 | break 394 | else: 395 | if system != "Windows" and stable_time > 0: 396 | # Try launching again if it vanished after being detected. 397 | subprocess.Popen(["xdg-open", "steam://"], 398 | stdout=subprocess.DEVNULL, 399 | stderr=subprocess.DEVNULL) 400 | stable_time = 0 401 | time.sleep(poll_interval) 402 | elapsed += poll_interval 403 | 404 | if stable_time >= stable_duration and steam_running(): 405 | write_log("Steam launched successfully", "Success", log_widget) 406 | else: 407 | level = "Warning" if system == "Windows" else "Error" 408 | write_log("Steam did not launch successfully", level, log_widget) 409 | except Exception as e: 410 | write_log(f"Failed to open Steam: {e}", "Error", log_widget) 411 | write_log("Please start Steam manually", "Info", log_widget) 412 | 413 | 414 | def launch_game_via_steam(app_id, log_widget=None): 415 | uri = f"steam://rungameid/{app_id}" 416 | system = platform.system() 417 | last_error = None 418 | 419 | try: 420 | if system == "Windows": 421 | if steam_exe_path and os.path.exists(steam_exe_path): 422 | subprocess.Popen( 423 | [steam_exe_path, "-applaunch", str(app_id)], 424 | stdout=subprocess.DEVNULL, 425 | stderr=subprocess.DEVNULL 426 | ) 427 | else: 428 | write_log("Steam executable path not found; attempting to use system URI handler.", "Warning", log_widget) 429 | try: 430 | os.startfile(uri) # type: ignore[attr-defined] 431 | except AttributeError: 432 | subprocess.Popen(["cmd", "/c", "start", "", uri]) 433 | elif system == "Linux": 434 | commands = [] 435 | 436 | steam_cmd = None 437 | if steam_exe_path: 438 | if os.path.isabs(steam_exe_path) and os.path.exists(steam_exe_path): 439 | steam_cmd = steam_exe_path 440 | else: 441 | steam_cmd = shutil.which(steam_exe_path) 442 | if not steam_cmd: 443 | steam_cmd = shutil.which("steam") 444 | 445 | if steam_cmd: 446 | commands.append( 447 | { 448 | "cmd": [steam_cmd, "-applaunch", str(app_id)], 449 | "description": f"{os.path.basename(steam_cmd)} -applaunch" 450 | } 451 | ) 452 | 453 | xdg = shutil.which("xdg-open") 454 | if xdg: 455 | commands.append( 456 | { 457 | "cmd": [xdg, uri], 458 | "description": "xdg-open steam URI", 459 | "check": True 460 | } 461 | ) 462 | 463 | # Fallback if nothing else is available 464 | if not commands: 465 | commands.append( 466 | { 467 | "cmd": [steam_exe_path or "steam", uri], 468 | "description": "steam URI fallback" 469 | } 470 | ) 471 | 472 | for entry in commands: 473 | try: 474 | if entry.get("check"): 475 | result = subprocess.run( 476 | entry["cmd"], 477 | check=False, 478 | stdout=subprocess.DEVNULL, 479 | stderr=subprocess.DEVNULL 480 | ) 481 | if result.returncode != 0: 482 | last_error = f"{entry['cmd'][0]} exited with code {result.returncode}" 483 | continue 484 | else: 485 | subprocess.Popen( 486 | entry["cmd"], 487 | stdout=subprocess.DEVNULL, 488 | stderr=subprocess.DEVNULL 489 | ) 490 | write_log( 491 | f"Launched Black Ops III via Steam (AppID: {app_id}) using {entry['description']}", 492 | "Success", 493 | log_widget 494 | ) 495 | return 496 | except FileNotFoundError: 497 | last_error = f"{entry['cmd'][0]} not found" 498 | continue 499 | except Exception as exc: 500 | last_error = str(exc) 501 | continue 502 | raise RuntimeError(last_error or "No suitable launcher command found") 503 | elif system == "Darwin": 504 | subprocess.Popen(["open", uri]) 505 | else: 506 | subprocess.Popen([steam_exe_path or "steam", uri]) 507 | write_log(f"Launched Black Ops III via Steam (AppID: {app_id})", "Success", log_widget) 508 | except FileNotFoundError: 509 | write_log("Steam client not found. Please verify your Steam installation path.", "Error", log_widget) 510 | except Exception as exc: 511 | write_log(f"Error launching game via Steam: {exc}", "Error", log_widget) 512 | 513 | 514 | def set_launch_options(user_id, app_id, launch_options, log_widget, preserve_fs_game=False): 515 | config_path = os.path.join(steam_userdata_path, user_id, "config", "localconfig.vdf") 516 | if not os.path.exists(config_path): 517 | write_log("localconfig.vdf not found!", "Error", log_widget) 518 | return False 519 | if not backup_config_file(config_path, log_widget): 520 | write_log("Aborting due to backup failure", "Error", log_widget) 521 | return False 522 | try: 523 | with open(config_path, "r", encoding="utf-8") as file: 524 | data = vdf.load(file) 525 | # Navigate to the apps section 526 | steam_config = data.setdefault("UserLocalConfigStore", {}) 527 | software = steam_config.setdefault("Software", {}) 528 | valve = software.setdefault("Valve", {}) 529 | steam = valve.setdefault("Steam", {}) 530 | apps = steam.setdefault("apps", {}) 531 | 532 | app_entry = apps.setdefault(app_id, {}) 533 | current_options = app_entry.get("LaunchOptions", "") 534 | requested_options = launch_options or "" 535 | 536 | # Parse existing options 537 | wine_override = 'WINEDLLOVERRIDES="dsound=n,b"' 538 | command_marker = "%command%" 539 | fs_game_pattern = r'\+set\s+fs_game\s+\S+' 540 | 541 | def strip_token(option_str, token): 542 | if not option_str or not token: 543 | return option_str 544 | return re.sub(rf'(? Tuple[int, ...]: 45 | sanitized = value.strip() 46 | if sanitized.startswith("v"): 47 | sanitized = sanitized[1:] 48 | if not sanitized: 49 | return (0,) 50 | numeric_parts = re.split(r"[^0-9]+", sanitized) 51 | normalized = [] 52 | for part in numeric_parts: 53 | if not part: 54 | continue 55 | try: 56 | normalized.append(int(part)) 57 | except ValueError: 58 | break 59 | return tuple(normalized) if normalized else (0,) 60 | 61 | 62 | def _download_checksum(url: str) -> Optional[str]: 63 | try: 64 | response = requests.get(url, timeout=15) 65 | response.raise_for_status() 66 | except requests.RequestException: 67 | return None 68 | content = response.text.strip() 69 | if not content: 70 | return None 71 | return content.split()[0] 72 | 73 | 74 | def _select_windows_asset(release_data: dict) -> Optional[ReleaseInfo]: 75 | assets = release_data.get("assets", []) 76 | checksum_lookup = {} 77 | for asset in assets: 78 | name = asset.get("name", "") 79 | if not name: 80 | continue 81 | if name.lower().endswith((".sha256", ".sha512", ".digest")): 82 | checksum_lookup[name.rsplit(".", 1)[0]] = asset.get("browser_download_url") 83 | 84 | preferred_asset = None 85 | fallback_asset = None 86 | for asset in assets: 87 | name = asset.get("name", "") 88 | download_url = asset.get("browser_download_url") 89 | if not name or not download_url: 90 | continue 91 | lowered = name.lower() 92 | if lowered.endswith(".exe"): 93 | preferred_asset = asset 94 | break 95 | if lowered.endswith(".zip") and fallback_asset is None: 96 | fallback_asset = asset 97 | 98 | selected = preferred_asset or fallback_asset 99 | if not selected: 100 | return None 101 | 102 | asset_name = selected.get("name", "") 103 | checksum_url = checksum_lookup.get(asset_name) 104 | download_url = selected.get("browser_download_url") 105 | if not download_url: 106 | return None 107 | 108 | return ReleaseInfo( 109 | version=release_data.get("tag_name") or release_data.get("name") or "0.0.0", 110 | name=release_data.get("name") or "PatchOpsIII", 111 | body=release_data.get("body") or "", 112 | asset_url=download_url, 113 | asset_name=asset_name, 114 | asset_size=selected.get("size") or 0, 115 | asset_content_type=selected.get("content_type") or "application/octet-stream", 116 | page_url=release_data.get("html_url") or GITHUB_RELEASE_PAGE_URL, 117 | checksum_url=checksum_url, 118 | ) 119 | 120 | 121 | def _select_linux_asset(release_data: dict) -> Optional[ReleaseInfo]: 122 | """Return release metadata when an AppImage (or zsync) asset exists.""" 123 | 124 | version = release_data.get("tag_name") or release_data.get("name") or "0.0.0" 125 | name = release_data.get("name") or "PatchOpsIII" 126 | body = release_data.get("body") or "" 127 | 128 | assets = release_data.get("assets", []) 129 | selected = None 130 | 131 | for asset in assets: 132 | asset_name = asset.get("name", "") 133 | if not asset_name: 134 | continue 135 | lowered = asset_name.lower() 136 | if lowered.endswith(".appimage"): 137 | selected = asset 138 | break 139 | if lowered.endswith(".appimage.zsync") and selected is None: 140 | selected = asset 141 | 142 | if not selected: 143 | return None 144 | 145 | download_url = selected.get("browser_download_url") or "" 146 | if not download_url: 147 | return None 148 | 149 | return ReleaseInfo( 150 | version=version, 151 | name=name, 152 | body=body, 153 | asset_url=download_url, 154 | asset_name=selected.get("name", "PatchOpsIII.AppImage"), 155 | asset_size=selected.get("size") or 0, 156 | asset_content_type=selected.get("content_type") or "application/octet-stream", 157 | page_url=release_data.get("html_url") or GITHUB_RELEASE_PAGE_URL, 158 | ) 159 | 160 | 161 | class UpdateCheckWorker(QThread): 162 | finished = Signal(object) 163 | failed = Signal(str) 164 | 165 | def __init__( 166 | self, 167 | current_version: str, 168 | api_url: str = GITHUB_LATEST_RELEASE_URL, 169 | *, 170 | asset_selector=_select_windows_asset, 171 | ): 172 | super().__init__() 173 | self.current_version = current_version 174 | self.api_url = api_url 175 | self.asset_selector = asset_selector 176 | 177 | def run(self) -> None: 178 | try: 179 | response = requests.get(self.api_url, timeout=30) 180 | response.raise_for_status() 181 | release_data = response.json() 182 | if release_data.get("draft") or release_data.get("prerelease"): 183 | self.finished.emit(None) 184 | return 185 | release = self.asset_selector(release_data) 186 | if not release or not release.asset_url: 187 | self.finished.emit(None) 188 | return 189 | if _normalize_version(release.version) <= _normalize_version(self.current_version): 190 | self.finished.emit(None) 191 | return 192 | self.finished.emit(release) 193 | except requests.RequestException as exc: 194 | self.failed.emit(str(exc)) 195 | except ValueError as exc: 196 | self.failed.emit(f"Failed to parse release metadata: {exc}") 197 | 198 | 199 | class UpdateDownloadWorker(QThread): 200 | progress = Signal(int) 201 | finished = Signal(str) 202 | failed = Signal(str) 203 | 204 | def __init__(self, release: ReleaseInfo): 205 | super().__init__() 206 | self.release = release 207 | self._checksum: Optional[str] = None 208 | 209 | def run(self) -> None: 210 | try: 211 | if self.release.checksum_url: 212 | self._checksum = _download_checksum(self.release.checksum_url) 213 | with requests.get(self.release.asset_url, stream=True, timeout=30) as response: 214 | response.raise_for_status() 215 | total_size = int(response.headers.get("Content-Length") or self.release.asset_size or 0) 216 | downloaded = 0 217 | fd, temp_path = tempfile.mkstemp(prefix="patchopsiii_update_", suffix=os.path.splitext(self.release.asset_name)[1]) 218 | os.close(fd) 219 | sha256 = hashlib.sha256() if self._checksum else None 220 | with open(temp_path, "wb") as handle: 221 | for chunk in response.iter_content(chunk_size=1024 * 512): 222 | if not chunk: 223 | continue 224 | handle.write(chunk) 225 | downloaded += len(chunk) 226 | if sha256 is not None: 227 | sha256.update(chunk) 228 | if total_size: 229 | percent = max(1, int(downloaded * 100 / total_size)) 230 | self.progress.emit(min(percent, 100)) 231 | if sha256 is not None and self._checksum: 232 | digest = sha256.hexdigest() 233 | if digest.lower() != self._checksum.lower(): 234 | os.remove(temp_path) 235 | raise ValueError("Checksum mismatch for downloaded update") 236 | self.progress.emit(100) 237 | self.finished.emit(temp_path) 238 | except Exception as exc: # noqa: BLE001 - propagate all errors 239 | self.failed.emit(str(exc)) 240 | 241 | 242 | class WindowsUpdater(QObject): 243 | """Manage update discovery and installation for Windows builds.""" 244 | 245 | check_started = Signal() 246 | check_failed = Signal(str) 247 | no_update_available = Signal() 248 | update_available = Signal(object) 249 | download_started = Signal(object) 250 | download_progress = Signal(int) 251 | download_failed = Signal(str) 252 | update_staged = Signal(object, str) 253 | 254 | def __init__( 255 | self, 256 | current_version: str, 257 | install_dir: str, 258 | executable_path: str, 259 | *, 260 | is_frozen: bool, 261 | api_url: str = GITHUB_LATEST_RELEASE_URL, 262 | log_widget=None, 263 | ) -> None: 264 | super().__init__() 265 | self.current_version = current_version 266 | self.install_dir = os.path.abspath(install_dir) if install_dir else "" 267 | self.executable_path = os.path.abspath(executable_path) 268 | self.is_frozen = is_frozen 269 | self.api_url = api_url 270 | self._log_widget = log_widget 271 | self._cached_result: Optional[Tuple[str, Optional[ReleaseInfo]]] = None 272 | self._last_check = 0.0 273 | self._check_worker: Optional[UpdateCheckWorker] = None 274 | self._download_worker: Optional[UpdateDownloadWorker] = None 275 | self._staged_script: Optional[str] = None 276 | self._staged_release: Optional[ReleaseInfo] = None 277 | 278 | def set_log_widget(self, widget) -> None: 279 | self._log_widget = widget 280 | 281 | def _log(self, message: str, category: str = "Info") -> None: 282 | write_log(message, category, self._log_widget) 283 | 284 | def check_for_updates(self, *, force: bool = False) -> None: 285 | if platform.system() != "Windows": 286 | self.check_failed.emit("Windows updater is only available on Windows.") 287 | return 288 | if self._check_worker is not None: 289 | return 290 | if not force and self._cached_result and (time.time() - self._last_check) < CACHE_TTL_SECONDS: 291 | state, release = self._cached_result 292 | if state == "available" and release: 293 | self.update_available.emit(release) 294 | else: 295 | self.no_update_available.emit() 296 | return 297 | self._log("Checking for updates...") 298 | self.check_started.emit() 299 | self._check_worker = UpdateCheckWorker( 300 | self.current_version, 301 | self.api_url, 302 | asset_selector=_select_windows_asset, 303 | ) 304 | self._check_worker.finished.connect(self._on_check_finished) 305 | self._check_worker.failed.connect(self._on_check_failed) 306 | self._check_worker.start() 307 | 308 | def _on_check_finished(self, release: Optional[ReleaseInfo]) -> None: 309 | self._last_check = time.time() 310 | if release: 311 | self._cached_result = ("available", release) 312 | self._log(f"Update available: {release.version}", "Success") 313 | self.update_available.emit(release) 314 | else: 315 | self._cached_result = ("none", None) 316 | self._log("No updates available.", "Info") 317 | self.no_update_available.emit() 318 | self._check_worker = None 319 | 320 | def _on_check_failed(self, message: str) -> None: 321 | self._last_check = time.time() 322 | self._cached_result = None 323 | self._log(f"Update check failed: {message}", "Error") 324 | self.check_failed.emit(message) 325 | self._check_worker = None 326 | 327 | def download_update(self, release: ReleaseInfo) -> None: 328 | if self._download_worker is not None: 329 | return 330 | self._log(f"Starting download for PatchOpsIII {release.version}...") 331 | self.download_started.emit(release) 332 | self._download_worker = UpdateDownloadWorker(release) 333 | self._download_worker.progress.connect(self.download_progress.emit) 334 | self._download_worker.finished.connect(lambda path: self._on_download_finished(release, path)) 335 | self._download_worker.failed.connect(self._on_download_failed) 336 | self._download_worker.start() 337 | 338 | def _on_download_finished(self, release: ReleaseInfo, path: str) -> None: 339 | self._log("Download completed. Preparing installation...", "Success") 340 | try: 341 | script = self._stage_update(release, path) 342 | except Exception as exc: # noqa: BLE001 343 | self._log(f"Failed to stage update: {exc}", "Error") 344 | self.download_failed.emit(str(exc)) 345 | if os.path.exists(path): 346 | os.remove(path) 347 | self._download_worker = None 348 | return 349 | self._staged_release = release 350 | self._staged_script = script 351 | self.update_staged.emit(release, script) 352 | self._download_worker = None 353 | 354 | def _on_download_failed(self, message: str) -> None: 355 | self._log(f"Update download failed: {message}", "Error") 356 | self.download_failed.emit(message) 357 | self._download_worker = None 358 | 359 | def _stage_update(self, release: ReleaseInfo, downloaded_path: str) -> str: 360 | if not self.is_frozen: 361 | raise RuntimeError("Automatic updates are only supported in packaged builds.") 362 | if not os.path.isdir(self.install_dir): 363 | raise RuntimeError("Unable to locate installation directory.") 364 | os.makedirs(self.install_dir, exist_ok=True) 365 | target_suffix = os.path.splitext(release.asset_name)[1] 366 | staged_path = os.path.join(self.install_dir, f"PatchOpsIII_update{target_suffix}") 367 | if os.path.exists(staged_path): 368 | if os.path.isdir(staged_path): 369 | shutil.rmtree(staged_path) 370 | else: 371 | os.remove(staged_path) 372 | if release.asset_name.lower().endswith(".zip"): 373 | extract_dir = staged_path 374 | os.makedirs(extract_dir, exist_ok=True) 375 | with zipfile.ZipFile(downloaded_path, "r") as archive: 376 | archive.extractall(extract_dir) 377 | os.remove(downloaded_path) 378 | return self._write_zip_swap_script(extract_dir) 379 | shutil.move(downloaded_path, staged_path) 380 | return self._write_exe_swap_script(staged_path) 381 | 382 | def _write_exe_swap_script(self, staged_executable: str) -> str: 383 | script_path = os.path.join(self.install_dir, "apply_patchopsiii_update.bat") 384 | backup_path = self.executable_path + ".old" 385 | vbs_path = os.path.join(self.install_dir, "run_patchopsiii_update.vbs") 386 | lines = [ 387 | "@echo off", 388 | "setlocal enableextensions", 389 | f"set PID={os.getpid()}", 390 | f"set TARGET={self.executable_path}", 391 | f"set UPDATED={staged_executable}", 392 | f"set BACKUP={backup_path}", 393 | f"set VBSFILE={vbs_path}", 394 | ":wait_loop", 395 | "timeout /t 1 /nobreak >nul", 396 | "tasklist /FI \"PID eq %PID%\" | findstr /I \"%PID%\" >nul", 397 | "if %ERRORLEVEL%==0 goto wait_loop", 398 | "if exist \"%BACKUP%\" del /f /q \"%BACKUP%\"", 399 | "if exist \"%TARGET%\" move /y \"%TARGET%\" \"%BACKUP%\"", 400 | "move /y \"%UPDATED%\" \"%TARGET%\"", 401 | "start \"\" \"%TARGET%\"", 402 | "if exist \"%BACKUP%\" del /f /q \"%BACKUP%\"", 403 | "if defined VBSFILE if exist \"%VBSFILE%\" del /f /q \"%VBSFILE%\"", 404 | "del /f /q \"%~f0\"", 405 | ] 406 | with open(script_path, "w", encoding="utf-8", newline="\r\n") as handle: 407 | handle.write("\n".join(lines)) 408 | return script_path 409 | 410 | def _write_zip_swap_script(self, extract_dir: str) -> str: 411 | script_path = os.path.join(self.install_dir, "apply_patchopsiii_update.bat") 412 | backup_path = self.executable_path + ".old" 413 | vbs_path = os.path.join(self.install_dir, "run_patchopsiii_update.vbs") 414 | lines = [ 415 | "@echo off", 416 | "setlocal enableextensions", 417 | f"set PID={os.getpid()}", 418 | f"set SOURCE={extract_dir}", 419 | f"set TARGET={self.install_dir}", 420 | f"set EXECUTABLE={self.executable_path}", 421 | f"set BACKUP={backup_path}", 422 | f"set VBSFILE={vbs_path}", 423 | ":wait_loop", 424 | "timeout /t 1 /nobreak >nul", 425 | "tasklist /FI \"PID eq %PID%\" | findstr /I \"%PID%\" >nul", 426 | "if %ERRORLEVEL%==0 goto wait_loop", 427 | "xcopy \"%SOURCE%\" \"%TARGET%\" /E /H /K /Y /I", 428 | "rmdir /S /Q \"%SOURCE%\"", 429 | "start \"\" \"%EXECUTABLE%\"", 430 | "if exist \"%BACKUP%\" del /f /q \"%BACKUP%\"", 431 | "if defined VBSFILE if exist \"%VBSFILE%\" del /f /q \"%VBSFILE%\"", 432 | "del /f /q \"%~f0\"", 433 | ] 434 | with open(script_path, "w", encoding="utf-8", newline="\r\n") as handle: 435 | handle.write("\n".join(lines)) 436 | return script_path 437 | 438 | def apply_staged_update(self) -> None: 439 | if not self._staged_script or not os.path.exists(self._staged_script): 440 | raise RuntimeError("No staged update is available.") 441 | self._log("Launching update installer and exiting...") 442 | cmd = [self._staged_script] 443 | 444 | # On Windows, wrap the batch in a VBS shim to avoid showing a console window. 445 | if os.name == "nt": 446 | vbs_path = os.path.join(self.install_dir, "run_patchopsiii_update.vbs") 447 | try: 448 | with open(vbs_path, "w", encoding="utf-8") as vbs: 449 | vbs.write( 450 | 'Set WshShell = CreateObject("WScript.Shell")\n' 451 | f'WshShell.Run """{self._staged_script}""", 0, False\n' 452 | ) 453 | creationflags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0 454 | subprocess.Popen( 455 | ["wscript.exe", vbs_path], 456 | creationflags=creationflags, 457 | startupinfo=self._hidden_startupinfo(), 458 | ) 459 | return 460 | except Exception as exc: # noqa: BLE001 461 | # Fallback to the normal batch invocation if VBS shim fails 462 | self._log(f"VBS wrapper failed, falling back to visible script: {exc}", "Warning") 463 | 464 | try: 465 | subprocess.Popen( 466 | cmd, 467 | creationflags=self._no_window_flags(), 468 | startupinfo=self._hidden_startupinfo(), 469 | ) 470 | except OSError as exc: 471 | raise RuntimeError(f"Failed to launch update script: {exc}") from exc 472 | 473 | def _no_window_flags(self) -> int: 474 | if os.name != "nt": 475 | return 0 476 | return subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0 477 | 478 | def _hidden_startupinfo(self): 479 | if os.name != "nt" or not hasattr(subprocess, "STARTUPINFO"): 480 | return None 481 | startupinfo = subprocess.STARTUPINFO() 482 | if hasattr(subprocess, "STARTF_USESHOWWINDOW"): 483 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 484 | startupinfo.wShowWindow = 0 485 | return startupinfo 486 | 487 | def reset(self) -> None: 488 | self._cached_result = None 489 | self._last_check = 0.0 490 | self._staged_script = None 491 | self._staged_release = None 492 | 493 | 494 | def _flatpak_exists() -> bool: 495 | return shutil.which("flatpak") is not None 496 | 497 | 498 | def _ensure_flathub_remote(log_callback) -> bool: 499 | try: 500 | result = subprocess.run( 501 | ["flatpak", "remotes", "--columns=name"], 502 | capture_output=True, 503 | text=True, 504 | check=True, 505 | ) 506 | if "flathub" in (result.stdout or ""): 507 | return True 508 | except Exception as exc: # noqa: BLE001 509 | log_callback(f"Failed to check Flatpak remotes: {exc}", "Warning") 510 | return False 511 | 512 | try: 513 | subprocess.run( 514 | ["flatpak", "remote-add", "--if-not-exists", "flathub", "https://flathub.org/repo/flathub.flatpakrepo"], 515 | check=True, 516 | capture_output=True, 517 | text=True, 518 | ) 519 | log_callback("Added Flathub remote for Gear Lever install.", "Info") 520 | return True 521 | except Exception as exc: # noqa: BLE001 522 | log_callback(f"Failed to add Flathub remote: {exc}", "Error") 523 | return False 524 | 525 | 526 | def _install_gear_lever(log_callback) -> bool: 527 | if not _flatpak_exists(): 528 | log_callback("Flatpak not found; cannot install Gear Lever automatically.", "Warning") 529 | return False 530 | if not _ensure_flathub_remote(log_callback): 531 | return False 532 | try: 533 | log_callback("Installing Gear Lever via Flatpak...", "Info") 534 | subprocess.run( 535 | ["flatpak", "install", "-y", "flathub", "it.mijorus.gearlever"], 536 | check=True, 537 | capture_output=True, 538 | text=True, 539 | ) 540 | log_callback("Gear Lever installed successfully.", "Success") 541 | return True 542 | except subprocess.CalledProcessError as exc: 543 | log_callback(f"Gear Lever install failed: {exc}", "Error") 544 | return False 545 | except Exception as exc: # noqa: BLE001 546 | log_callback(f"Unexpected error installing Gear Lever: {exc}", "Error") 547 | return False 548 | 549 | 550 | def _launch_gear_lever(log_callback) -> bool: 551 | """Attempt to launch Gear Lever via Flatpak first, then native binary.""" 552 | 553 | candidates = [ 554 | ["flatpak", "run", "it.mijorus.gearlever"] if _flatpak_exists() else None, 555 | ["gearlever"], 556 | ] 557 | 558 | for command in candidates: 559 | if not command: 560 | continue 561 | try: 562 | proc = subprocess.Popen( 563 | command, 564 | stdout=subprocess.DEVNULL, 565 | stderr=subprocess.DEVNULL, 566 | ) 567 | # If the process dies immediately with a non-zero exit code, treat as a failure 568 | time.sleep(1.0) 569 | if proc.poll() is not None and proc.returncode: 570 | raise RuntimeError(f"Exited early with code {proc.returncode}") 571 | log_callback( 572 | "Launching Gear Lever to manage the PatchOpsIII update.", 573 | "Success", 574 | ) 575 | return True 576 | except FileNotFoundError: 577 | log_callback( 578 | f"Command not found while attempting to launch Gear Lever: {' '.join(command)}", 579 | "Warning", 580 | ) 581 | except Exception as exc: # noqa: BLE001 - log unexpected errors 582 | log_callback( 583 | f"Failed to launch {' '.join(command)}: {exc}", 584 | "Error", 585 | ) 586 | 587 | return False 588 | 589 | 590 | def _show_gear_lever_required(parent, log_callback) -> None: 591 | """Inform the user that Gear Lever must be installed for automatic updates.""" 592 | 593 | log_callback( 594 | "Gear Lever is required for automatic updates on Linux.", 595 | "Warning", 596 | ) 597 | 598 | message_box = QMessageBox(parent) 599 | message_box.setWindowTitle("Gear Lever Required") 600 | message_box.setIcon(QMessageBox.Warning) 601 | message_box.setText( 602 | "Automatic updates on Linux require Gear Lever to be installed." 603 | ) 604 | message_box.setInformativeText( 605 | f'Install Gear Lever from Flathub to enable automatic updates.\n' 606 | ) 607 | message_box.setTextFormat(Qt.RichText) 608 | install_button = None 609 | if _flatpak_exists(): 610 | install_button = message_box.addButton("Install via Flatpak", QMessageBox.AcceptRole) 611 | open_button = message_box.addButton("Open Flathub Page", QMessageBox.ActionRole) 612 | close_button = message_box.addButton(QMessageBox.Close) 613 | 614 | message_box.setDefaultButton(install_button or open_button or close_button) 615 | message_box.setTextInteractionFlags(Qt.TextBrowserInteraction) 616 | 617 | message_box.exec() 618 | clicked = message_box.clickedButton() 619 | if clicked == install_button: 620 | if _install_gear_lever(log_callback): 621 | _launch_gear_lever(log_callback) 622 | else: 623 | QDesktopServices.openUrl(QUrl(GEAR_LEVER_URL)) 624 | elif clicked == open_button: 625 | QDesktopServices.openUrl(QUrl(GEAR_LEVER_URL)) 626 | 627 | 628 | def prompt_linux_update( 629 | parent, 630 | current_version: str, 631 | *, 632 | api_url: str = GITHUB_LATEST_RELEASE_URL, 633 | log_widget=None, 634 | ) -> None: 635 | """Check for Linux updates and prompt the user to launch Gear Lever if needed.""" 636 | 637 | if platform.system() != "Linux": 638 | return 639 | 640 | def log(message: str, category: str = "Info") -> None: 641 | write_log(message, category, log_widget) 642 | 643 | if getattr(parent, "_linux_update_worker", None): 644 | return 645 | 646 | log("Checking for Linux updates...", "Info") 647 | 648 | worker = UpdateCheckWorker( 649 | current_version, 650 | api_url, 651 | asset_selector=_select_linux_asset, 652 | ) 653 | parent._linux_update_worker = worker 654 | 655 | def _cleanup() -> None: 656 | if getattr(parent, "_linux_update_worker", None) is worker: 657 | parent._linux_update_worker = None 658 | worker.deleteLater() 659 | 660 | def _handle_finished(release: Optional[ReleaseInfo]) -> None: 661 | _cleanup() 662 | if not release: 663 | log("No updates available for the Linux build.") 664 | return 665 | 666 | message = ( 667 | f"PatchOpsIII {release.version} is available for download.\n\n" 668 | "Would you like to launch Gear Lever to apply the update now?" 669 | ) 670 | 671 | response = QMessageBox.question( 672 | parent, 673 | "Update Available", 674 | message, 675 | QMessageBox.Yes | QMessageBox.No, 676 | ) 677 | if response == QMessageBox.Yes: 678 | if not _launch_gear_lever(log): 679 | _show_gear_lever_required(parent, log) 680 | else: 681 | log("User deferred the Linux update.") 682 | 683 | def _handle_failed(message: str) -> None: 684 | _cleanup() 685 | log(f"Linux update check failed: {message}", "Error") 686 | 687 | worker.finished.connect(_handle_finished) 688 | worker.failed.connect(_handle_failed) 689 | worker.start() 690 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import re 4 | import json 5 | import stat 6 | import platform 7 | from typing import Optional 8 | from PySide6.QtWidgets import ( 9 | QMessageBox, QWidget, QGroupBox, QVBoxLayout, QHBoxLayout, QLabel, 10 | QComboBox, QPushButton, QFormLayout, QCheckBox, QSpinBox, QLineEdit, QSlider, 11 | QSizePolicy 12 | ) 13 | from PySide6.QtGui import QIntValidator, QGuiApplication 14 | from PySide6.QtCore import Qt, QTimer 15 | from version import APP_VERSION 16 | from utils import write_log, get_log_file_path 17 | 18 | def set_config_value(game_dir, key, value, comment, log_widget): 19 | config_path = os.path.join(game_dir, "players", "config.ini") 20 | pattern = rf'^\s*{re.escape(key)}\s*=' 21 | replacement = f'{key} = "{value}" // {comment}' 22 | try: 23 | update_config_values( 24 | config_path, 25 | {pattern: replacement}, 26 | f"Set {key} to {value}.", 27 | log_widget 28 | ) 29 | except Exception as e: 30 | write_log(f"Error setting {key}: {e}", "Error", log_widget) 31 | 32 | def update_config_values(config_path, changes, success_message, log_widget, suppress_output=False): 33 | if os.path.exists(config_path): 34 | try: 35 | with open(config_path, "r") as f: 36 | lines = f.readlines() 37 | new_lines = [] 38 | for line in lines: 39 | replaced = False 40 | for pattern, replacement in changes.items(): 41 | if re.search(pattern, line): 42 | new_lines.append(replacement + "\n") 43 | replaced = True 44 | break 45 | if not replaced: 46 | new_lines.append(line) 47 | with open(config_path, "w") as f: 48 | f.writelines(new_lines) 49 | if not suppress_output: 50 | write_log(success_message, "Success", log_widget) 51 | except PermissionError: 52 | QMessageBox.critical( 53 | None, "Permission Error", 54 | f"Cannot write to {config_path}.\nPlease run as administrator." 55 | ) 56 | except Exception as e: 57 | write_log(f"Error updating config: {e}", "Error", log_widget) 58 | else: 59 | write_log(f"config.ini not found at {config_path}.", "Error", log_widget) 60 | 61 | def toggle_stuttering_setting(game_dir, reduce_stutter, log_widget): 62 | dll_file = os.path.join(game_dir, "d3dcompiler_46.dll") 63 | dll_bak = dll_file + ".bak" 64 | if reduce_stutter: 65 | if os.path.exists(dll_file): 66 | try: 67 | os.rename(dll_file, dll_bak) 68 | write_log("Renamed d3dcompiler_46.dll to reduce stuttering.", "Success", log_widget) 69 | except Exception: 70 | write_log("Failed to rename d3dcompiler_46.dll.", "Error", log_widget) 71 | elif os.path.exists(dll_bak): 72 | write_log("Stutter reduction already enabled.", "Success", log_widget) 73 | else: 74 | write_log("d3dcompiler_46.dll not found.", "Warning", log_widget) 75 | else: 76 | if os.path.exists(dll_bak): 77 | try: 78 | os.rename(dll_bak, dll_file) 79 | write_log("Restored d3dcompiler_46.dll.", "Success", log_widget) 80 | except Exception: 81 | write_log("Failed to restore d3dcompiler_46.dll.", "Error", log_widget) 82 | else: 83 | write_log("Backup not found to restore.", "Warning", log_widget) 84 | 85 | def set_config_readonly(game_dir, read_only, log_widget): 86 | config_path = os.path.join(game_dir, "players", "config.ini") 87 | if os.path.exists(config_path): 88 | try: 89 | if read_only: 90 | os.chmod(config_path, stat.S_IREAD) 91 | write_log("config.ini set to read-only.", "Success", log_widget) 92 | else: 93 | os.chmod(config_path, stat.S_IWRITE | stat.S_IREAD) 94 | write_log("config.ini set to writable.", "Success", log_widget) 95 | except Exception as e: 96 | write_log(f"Failed to change config.ini permissions: {e}", "Error", log_widget) 97 | 98 | def load_presets_from_json(json_path): 99 | if not os.path.exists(json_path): 100 | return None 101 | try: 102 | with open(json_path, "r") as f: 103 | return json.load(f) 104 | except Exception: 105 | return None 106 | 107 | def apply_preset(game_dir, preset_name, log_widget, presets_dict): 108 | config_path = os.path.join(game_dir, "players", "config.ini") 109 | preset = presets_dict.get(preset_name) 110 | if preset is None: 111 | write_log(f"Preset {preset_name} not found.", "Error", log_widget) 112 | return 113 | changes = {} 114 | for setting, (value, comment) in preset.items(): 115 | if setting == "ReduceStutter": 116 | toggle_stuttering_setting(game_dir, True, log_widget) 117 | else: 118 | pattern = r'^\s*' + re.escape(setting) + r'\s*=' 119 | replacement = f'{setting} = "{value}" // {comment}' 120 | changes[pattern] = replacement 121 | if "BackbufferCount" in preset and preset["BackbufferCount"][0] == "3": 122 | pattern = r'^\s*Vsync\s*=' 123 | changes[pattern] = 'Vsync = "1" // Enabled with triple-buffered V-sync' 124 | update_config_values( 125 | config_path, 126 | changes, 127 | f"Applied preset '{preset_name}'.", 128 | log_widget 129 | ) 130 | 131 | def check_essential_status(game_dir): 132 | status = {} 133 | config_path = os.path.join(game_dir, "players", "config.ini") 134 | if os.path.exists(config_path): 135 | with open(config_path, "r") as f: 136 | content = f.read() 137 | match = re.search(r'MaxFPS\s*=\s*"([^"]+)"', content) 138 | status["max_fps"] = int(match.group(1)) if match else 165 139 | 140 | match = re.search(r'FOV\s*=\s*"([^"]+)"', content) 141 | status["fov"] = int(match.group(1)) if match else 80 142 | 143 | match = re.search(r'FullScreenMode\s*=\s*"([^"]+)"', content) 144 | status["display_mode"] = int(match.group(1)) if match else 1 145 | 146 | match = re.search(r'WindowSize\s*=\s*"([^"]+)"', content) 147 | status["resolution"] = match.group(1) if match else "2560x1440" 148 | 149 | match = re.search(r'RefreshRate\s*=\s*"([^"]+)"', content) 150 | status["refresh_rate"] = float(match.group(1)) if match else 165 151 | 152 | match = re.search(r'Vsync\s*=\s*"([^"]+)"', content) 153 | status["vsync"] = (match.group(1) == "1") if match else True 154 | 155 | match = re.search(r'DrawFPS\s*=\s*"([^"]+)"', content) 156 | status["draw_fps"] = (match.group(1) == "1") if match else False 157 | 158 | status["all_settings"] = bool(re.search(r'RestrictGraphicsOptions\s*=\s*"0"', content)) 159 | status["smooth"] = bool(re.search(r'SmoothFramerate\s*=\s*"1"', content)) 160 | 161 | # Update VRAM status check logic 162 | video_memory_match = re.search(r'VideoMemory\s*=\s*"([^"]+)"', content) 163 | stream_resident_match = re.search(r'StreamMinResident\s*=\s*"([^"]+)"', content) 164 | 165 | has_full_vram = (video_memory_match and video_memory_match.group(1) == "1" and 166 | stream_resident_match and stream_resident_match.group(1) == "0") 167 | 168 | status["vram"] = not has_full_vram 169 | if video_memory_match and not has_full_vram: 170 | status["vram_value"] = float(video_memory_match.group(1)) 171 | else: 172 | status["vram_value"] = 0.75 # Default value when not set 173 | 174 | match = re.search(r'MaxFrameLatency\s*=\s*"(\d)"', content) 175 | status["latency"] = int(match.group(1)) if match else 1 176 | 177 | status["reduce_cpu"] = bool(re.search(r'SerializeRender\s*=\s*"2"', content)) 178 | 179 | video_dir = os.path.join(game_dir, "video") 180 | intro_bak = os.path.join(video_dir, "BO3_Global_Logo_LogoSequence.mkv.bak") 181 | status["skip_intro"] = os.path.exists(intro_bak) 182 | else: 183 | status = { 184 | "max_fps": 60, 185 | "fov": 80, 186 | "display_mode": 1, 187 | "resolution": "1920x1080", 188 | "refresh_rate": 60, 189 | "vsync": True, 190 | "draw_fps": False, 191 | "all_settings": False, 192 | "smooth": False, 193 | "vram": False, 194 | "vram_value": 0.75, 195 | "latency": 1, 196 | "reduce_cpu": False, 197 | "skip_intro": False, 198 | } 199 | return status 200 | 201 | class GraphicsSettingsWidget(QWidget): 202 | def __init__(self, dxvk_widget=None, parent=None): 203 | super().__init__(parent) 204 | self.dxvk_widget = dxvk_widget # Reference to the DXVK widget 205 | self.game_dir = None 206 | self.log_widget = None 207 | self.preset_dict = {} 208 | self.init_ui() 209 | 210 | self._pending_fov_value = None 211 | self._last_applied_fov = None 212 | self._fov_update_timer = QTimer(self) 213 | self._fov_update_timer.setSingleShot(True) 214 | self._fov_update_timer.setInterval(250) 215 | self._fov_update_timer.timeout.connect(self._commit_pending_fov_value) 216 | 217 | def init_ui(self): 218 | main_layout = QVBoxLayout(self) 219 | main_layout.setContentsMargins(0, 0, 0, 0) 220 | main_layout.setSpacing(0) 221 | 222 | # ================= Graphics Presets ================= 223 | presets_group = QGroupBox("Graphics Presets") 224 | presets_layout = QHBoxLayout(presets_group) 225 | presets_layout.addWidget(QLabel("Select Preset:")) 226 | 227 | self.preset_combo = QComboBox() 228 | self.preset_combo.addItems(["Quality", "Balanced", "Performance", "Ultra Performance", "Custom"]) 229 | presets_layout.addWidget(self.preset_combo) 230 | 231 | self.apply_preset_btn = QPushButton("Apply Preset") 232 | self.apply_preset_btn.clicked.connect(self.apply_preset_clicked) 233 | presets_layout.addWidget(self.apply_preset_btn) 234 | 235 | if self.dxvk_widget: 236 | self.dxvk_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) 237 | presets_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) 238 | top_row_layout = QHBoxLayout() 239 | top_row_layout.addWidget(self.dxvk_widget) 240 | top_row_layout.addWidget(presets_group) 241 | main_layout.addLayout(top_row_layout) 242 | else: 243 | main_layout.addWidget(presets_group) 244 | 245 | # ================= Graphics Settings Section ================= 246 | settings_group = QGroupBox("Graphics Settings") 247 | settings_layout = QVBoxLayout(settings_group) 248 | 249 | settings_form = QFormLayout() 250 | 251 | # Create horizontal layout for checkboxes 252 | checkbox_layout = QHBoxLayout() 253 | self.vsync_cb = QCheckBox("Enable V-Sync") 254 | self.vsync_cb.stateChanged.connect(self.vsync_changed) 255 | self.draw_fps_cb = QCheckBox("Show FPS Counter") 256 | self.draw_fps_cb.stateChanged.connect(self.draw_fps_changed) 257 | checkbox_layout.addWidget(self.vsync_cb) 258 | checkbox_layout.addWidget(self.draw_fps_cb) 259 | checkbox_layout.addStretch() 260 | settings_form.addRow(checkbox_layout) 261 | 262 | self.fps_limiter_spin = QSpinBox() 263 | self.fps_limiter_spin.setRange(0, 1000) 264 | self.fps_limiter_spin.setValue(165) 265 | self.fps_limiter_spin.valueChanged.connect(self.fps_limiter_changed) 266 | settings_form.addRow("FPS Limiter (0=Unlimited):", self.fps_limiter_spin) 267 | 268 | self.fov_slider = QSlider(Qt.Horizontal) 269 | self.fov_slider.setRange(65, 120) 270 | self.fov_slider.setTickInterval(5) 271 | self.fov_slider.setSingleStep(1) 272 | self.fov_slider.setValue(80) 273 | self.fov_slider.valueChanged.connect(self.on_fov_slider_changed) 274 | 275 | self.fov_input = QLineEdit("80") 276 | self.fov_input.setValidator(QIntValidator(65, 120, self.fov_input)) 277 | self.fov_input.setFixedWidth(60) 278 | self.fov_input.editingFinished.connect(self.on_fov_input_edited) 279 | 280 | fov_container = QWidget() 281 | fov_layout = QHBoxLayout(fov_container) 282 | fov_layout.setContentsMargins(0, 0, 0, 0) 283 | fov_layout.setSpacing(8) 284 | fov_layout.addWidget(self.fov_slider) 285 | fov_layout.addWidget(self.fov_input) 286 | 287 | settings_form.addRow("FOV:", fov_container) 288 | 289 | self.display_mode_combo = QComboBox() 290 | self.display_mode_combo.addItems(["Windowed", "Fullscreen", "Fullscreen Windowed"]) 291 | self.display_mode_combo.currentIndexChanged.connect(self.display_mode_changed) 292 | settings_form.addRow("Display Mode:", self.display_mode_combo) 293 | 294 | self.resolution_edit = QLineEdit("2560x1440") 295 | self.resolution_edit.editingFinished.connect(self.resolution_changed) 296 | settings_form.addRow("Resolution:", self.resolution_edit) 297 | 298 | self.refresh_rate_spin = QSpinBox() 299 | self.refresh_rate_spin.setRange(1, 240) 300 | self.refresh_rate_spin.setValue(165) 301 | self.refresh_rate_spin.valueChanged.connect(self.refresh_rate_changed) 302 | settings_form.addRow("Refresh Rate:", self.refresh_rate_spin) 303 | 304 | self.render_res_spin = QSpinBox() 305 | self.render_res_spin.setRange(50, 200) 306 | self.render_res_spin.setSingleStep(10) 307 | self.render_res_spin.setValue(100) 308 | self.render_res_spin.valueChanged.connect(self.render_res_percent_changed) 309 | settings_form.addRow("Render Res %:", self.render_res_spin) 310 | 311 | settings_layout.addLayout(settings_form) 312 | main_layout.addWidget(settings_group) 313 | 314 | def set_game_directory(self, game_dir): 315 | self.game_dir = game_dir 316 | json_path = os.path.join(os.path.dirname(__file__), "presets.json") 317 | loaded = load_presets_from_json(json_path) 318 | self.preset_dict = loaded if loaded else {} 319 | 320 | self.preset_combo.clear() 321 | for preset_name in self.preset_dict.keys(): 322 | self.preset_combo.addItem(preset_name) 323 | 324 | if not self.game_dir or not os.path.exists(self.game_dir): 325 | write_log(f"Game directory does not exist: {self.game_dir}", "Error", self.log_widget) 326 | return 327 | 328 | config_path = os.path.join(self.game_dir, "players", "config.ini") 329 | if os.path.exists(config_path) and not os.access(config_path, os.W_OK): 330 | set_config_readonly(self.game_dir, False, self.log_widget) 331 | 332 | self.initialize_status() 333 | 334 | def set_log_widget(self, log_widget): 335 | self.log_widget = log_widget 336 | 337 | def initialize_status(self): 338 | if not self.game_dir: 339 | return 340 | status = check_essential_status(self.game_dir) 341 | 342 | self.fps_limiter_spin.setValue(status.get("max_fps", 165)) 343 | self._fov_update_timer.stop() 344 | fov_value = status.get("fov", 80) 345 | self.fov_slider.blockSignals(True) 346 | self.fov_slider.setValue(fov_value) 347 | self.fov_slider.blockSignals(False) 348 | self.fov_input.blockSignals(True) 349 | self.fov_input.setText(str(fov_value)) 350 | self.fov_input.blockSignals(False) 351 | self._last_applied_fov = fov_value 352 | self._pending_fov_value = None 353 | self.display_mode_combo.setCurrentIndex(status.get("display_mode", 1)) 354 | self.resolution_edit.setText(status.get("resolution", "2560x1440")) 355 | self.refresh_rate_spin.setValue(status.get("refresh_rate", 165)) 356 | self.vsync_cb.setChecked(status.get("vsync", True)) 357 | self.draw_fps_cb.setChecked(status.get("draw_fps", False)) 358 | 359 | def apply_preset_clicked(self): 360 | if not self.game_dir or not os.path.exists(self.game_dir): 361 | write_log(f"Game directory does not exist: {self.game_dir}", "Error", self.log_widget) 362 | return 363 | if self.log_widget and hasattr(self, 'lock_config_cb') and self.lock_config_cb.isChecked(): 364 | set_config_readonly(self.game_dir, False, self.log_widget) 365 | 366 | preset_name = self.preset_combo.currentText() 367 | apply_preset(self.game_dir, preset_name, self.log_widget, self.preset_dict) 368 | write_log(f"Applied preset '{preset_name}'.", "Success", self.log_widget) 369 | 370 | if self.log_widget and hasattr(self, 'lock_config_cb') and self.lock_config_cb.isChecked(): 371 | set_config_readonly(self.game_dir, True, self.log_widget) 372 | 373 | self.initialize_status() 374 | 375 | def fps_limiter_changed(self): 376 | if not self.game_dir: 377 | return 378 | set_config_value(self.game_dir, "MaxFPS", str(self.fps_limiter_spin.value()), "0 to 1000", self.log_widget) 379 | 380 | def on_fov_slider_changed(self, value): 381 | self.fov_input.blockSignals(True) 382 | self.fov_input.setText(str(value)) 383 | self.fov_input.blockSignals(False) 384 | if not self.game_dir: 385 | self._pending_fov_value = None 386 | return 387 | self._pending_fov_value = value 388 | self._fov_update_timer.start() 389 | 390 | def on_fov_input_edited(self): 391 | text = self.fov_input.text().strip() 392 | if not text: 393 | self.fov_input.setText(str(self.fov_slider.value())) 394 | return 395 | try: 396 | value = int(text) 397 | except ValueError: 398 | value = self.fov_slider.value() 399 | value = max(65, min(120, value)) 400 | if str(value) != text: 401 | self.fov_input.setText(str(value)) 402 | if self.fov_slider.value() != value: 403 | self.fov_slider.blockSignals(True) 404 | self.fov_slider.setValue(value) 405 | self.fov_slider.blockSignals(False) 406 | self._pending_fov_value = value 407 | if not self.game_dir: 408 | return 409 | self._commit_pending_fov_value() 410 | 411 | def _commit_pending_fov_value(self): 412 | if self._pending_fov_value is None: 413 | return 414 | if not self.game_dir: 415 | self._pending_fov_value = None 416 | return 417 | value = self._pending_fov_value 418 | self._pending_fov_value = None 419 | self._apply_fov_value(value) 420 | 421 | def _apply_fov_value(self, value): 422 | self._fov_update_timer.stop() 423 | if not self.game_dir: 424 | self._last_applied_fov = value 425 | self._pending_fov_value = None 426 | return 427 | if self._last_applied_fov == value: 428 | return 429 | self._last_applied_fov = value 430 | self._pending_fov_value = None 431 | set_config_value(self.game_dir, "FOV", str(value), "65 to 120", self.log_widget) 432 | 433 | def display_mode_changed(self): 434 | if not self.game_dir: 435 | return 436 | mode_index = self.display_mode_combo.currentIndex() 437 | set_config_value( 438 | self.game_dir, 439 | "FullScreenMode", 440 | str(mode_index), 441 | "0=Windowed,1=Fullscreen,2=Fullscreen Windowed", 442 | self.log_widget 443 | ) 444 | 445 | def resolution_changed(self): 446 | if not self.game_dir: 447 | return 448 | res = self.resolution_edit.text().strip() 449 | set_config_value(self.game_dir, "WindowSize", res, "any text", self.log_widget) 450 | 451 | def refresh_rate_changed(self): 452 | if not self.game_dir: 453 | return 454 | set_config_value( 455 | self.game_dir, 456 | "RefreshRate", 457 | str(self.refresh_rate_spin.value()), 458 | "1 to 240", 459 | self.log_widget 460 | ) 461 | 462 | def render_res_percent_changed(self): 463 | if not self.game_dir: 464 | return 465 | set_config_value( 466 | self.game_dir, 467 | "ResolutionPercent", 468 | str(self.render_res_spin.value()), 469 | "50 to 200", 470 | self.log_widget 471 | ) 472 | 473 | def vsync_changed(self): 474 | if not self.game_dir: 475 | return 476 | val = "1" if self.vsync_cb.isChecked() else "0" 477 | set_config_value(self.game_dir, "Vsync", val, "0 or 1", self.log_widget) 478 | 479 | def draw_fps_changed(self): 480 | if not self.game_dir: 481 | return 482 | val = "1" if self.draw_fps_cb.isChecked() else "0" 483 | set_config_value(self.game_dir, "DrawFPS", val, "0 or 1", self.log_widget) 484 | 485 | # ================= Advanced Settings Widget ================= 486 | class AdvancedSettingsWidget(QWidget): 487 | def __init__(self, parent=None): 488 | super().__init__(parent) 489 | self.game_dir = None 490 | self.log_widget = None 491 | self.version_label: Optional[QLabel] = None 492 | self.init_ui() 493 | 494 | def load_settings(self): 495 | """Load settings from config.ini and update UI elements""" 496 | if not self.game_dir: 497 | return 498 | 499 | status = check_essential_status(self.game_dir) 500 | 501 | # Update UI elements with loaded settings 502 | self.smooth_cb.setChecked(status.get("smooth", False)) 503 | self.vram_cb.setChecked(status.get("vram", False)) 504 | self.vram_limit_spin.setEnabled(status.get("vram", False)) 505 | 506 | # Set VRAM limit using the value from status 507 | vram_value = status.get("vram_value", 0.75) 508 | self.vram_limit_spin.setValue(int(vram_value * 100)) 509 | 510 | self.latency_spin.setValue(status.get("latency", 1)) 511 | self.reduce_cpu_cb.setChecked(status.get("reduce_cpu", False)) 512 | self.all_settings_cb.setChecked(status.get("all_settings", False)) 513 | 514 | # Check config file read-only status 515 | config_path = os.path.join(self.game_dir, "players", "config.ini") 516 | if os.path.exists(config_path): 517 | self.lock_config_cb.setChecked(not os.access(config_path, os.W_OK)) 518 | 519 | def refresh_settings(self): 520 | """Refresh all advanced settings from the current game directory""" 521 | if self.game_dir: 522 | self.load_settings() 523 | 524 | def init_ui(self): 525 | layout = QVBoxLayout(self) 526 | adv_box = QGroupBox("Advanced Settings") 527 | adv_form = QFormLayout(adv_box) 528 | 529 | self.smooth_cb = QCheckBox("Smooth Framerate (Enables the smoothframe rate option)") 530 | self.smooth_cb.stateChanged.connect(self.smooth_changed) 531 | adv_form.addRow(self.smooth_cb) 532 | 533 | # VRAM settings 534 | self.vram_cb = QCheckBox("Set VRAM Usage (Enables the VideoMemory and StreamMinResident options)") 535 | self.vram_cb.stateChanged.connect(self.vram_changed) 536 | adv_form.addRow(self.vram_cb) 537 | 538 | # New spin box for limited VRAM percentage 539 | self.vram_limit_spin = QSpinBox() 540 | self.vram_limit_spin.setRange(75, 100) 541 | self.vram_limit_spin.setValue(75) 542 | self.vram_limit_spin.setEnabled(False) 543 | self.vram_limit_spin.valueChanged.connect(self.vram_limit_changed) 544 | adv_form.addRow("Set VRAM target to (%):", self.vram_limit_spin) 545 | 546 | self.latency_spin = QSpinBox() 547 | self.latency_spin.setRange(0, 4) 548 | self.latency_spin.setValue(1) 549 | self.latency_spin.valueChanged.connect(self.latency_changed) 550 | adv_form.addRow("Lower Latency (0-4, determines the number of frames to queue before rendering):", self.latency_spin) 551 | 552 | self.reduce_cpu_cb = QCheckBox("Reduce CPU Usage (Changes the SerializeRender option, only recommended for weak CPUs)") 553 | self.reduce_cpu_cb.stateChanged.connect(self.reduce_cpu_changed) 554 | adv_form.addRow(self.reduce_cpu_cb) 555 | 556 | self.all_settings_cb = QCheckBox("Unlock All Graphics Options (Allows all graphics options to be changed)") 557 | self.all_settings_cb.stateChanged.connect(self.all_settings_changed) 558 | adv_form.addRow(self.all_settings_cb) 559 | 560 | self.lock_config_cb = QCheckBox("Lock config.ini (read-only mode, prevents changes to the config file)") 561 | self.lock_config_cb.stateChanged.connect(self.lock_config_changed) 562 | adv_form.addRow(self.lock_config_cb) 563 | 564 | layout.addWidget(adv_box) 565 | layout.addStretch(1) 566 | 567 | footer_layout = QHBoxLayout() 568 | self.version_label = QLabel(f"PatchOpsIII v{APP_VERSION}") 569 | footer_layout.addWidget(self.version_label) 570 | footer_layout.addStretch(1) 571 | 572 | copy_logs_btn = QPushButton("Copy Logs") 573 | copy_logs_btn.clicked.connect(self.copy_logs_to_clipboard) 574 | footer_layout.addWidget(copy_logs_btn) 575 | 576 | layout.addLayout(footer_layout) 577 | 578 | def set_game_directory(self, game_dir): 579 | self.game_dir = game_dir 580 | if self.game_dir: 581 | self.load_settings() # Load settings immediately when directory is set 582 | config_path = os.path.join(game_dir, "players", "config.ini") 583 | if os.path.exists(config_path): 584 | self.lock_config_cb.setChecked(not os.access(config_path, os.W_OK)) 585 | 586 | def set_log_widget(self, log_widget): 587 | self.log_widget = log_widget 588 | 589 | def smooth_changed(self): 590 | val = "1" if self.smooth_cb.isChecked() else "0" 591 | set_config_value(self.game_dir, "SmoothFramerate", val, "0 or 1", self.log_widget) 592 | 593 | def vram_changed(self): 594 | config_path = os.path.join(self.game_dir, "players", "config.ini") 595 | if self.vram_cb.isChecked(): 596 | # When checked, limited VRAM is enabled: 597 | self.vram_limit_spin.setEnabled(True) 598 | self.vram_limit_changed() # Apply limited percentage setting. 599 | else: 600 | # When unchecked, full VRAM usage is enabled: 601 | self.vram_limit_spin.setEnabled(False) 602 | pattern_replacements = { 603 | r'^\s*VideoMemory\s*=': 'VideoMemory = "1" // 0.75 to 1', 604 | r'^\s*StreamMinResident\s*=': 'StreamMinResident = "0" // 0 or 1', 605 | } 606 | update_config_values(config_path, pattern_replacements, "Enabled full VRAM usage.", self.log_widget) 607 | 608 | def vram_limit_changed(self): 609 | config_path = os.path.join(self.game_dir, "players", "config.ini") 610 | percentage = self.vram_limit_spin.value() 611 | decimal_value = percentage / 100.0 612 | pattern_replacements = { 613 | r'^\s*VideoMemory\s*=': f'VideoMemory = "{decimal_value}" // 0.75 to 1', 614 | r'^\s*StreamMinResident\s*=': 'StreamMinResident = "1" // 0 or 1', 615 | } 616 | update_config_values(config_path, pattern_replacements, f"Limited VRAM usage set to {percentage}%.", self.log_widget) 617 | 618 | def latency_changed(self): 619 | set_config_value(self.game_dir, "MaxFrameLatency", str(self.latency_spin.value()), "0 to 4", self.log_widget) 620 | 621 | def reduce_cpu_changed(self): 622 | val = "2" if self.reduce_cpu_cb.isChecked() else "0" 623 | set_config_value(self.game_dir, "SerializeRender", val, "0 to 2", self.log_widget) 624 | 625 | def all_settings_changed(self): 626 | val = "0" if self.all_settings_cb.isChecked() else "1" 627 | set_config_value(self.game_dir, "RestrictGraphicsOptions", val, "0 or 1", self.log_widget) 628 | 629 | def lock_config_changed(self): 630 | if self.lock_config_cb.isChecked(): 631 | set_config_readonly(self.game_dir, True, self.log_widget) 632 | else: 633 | set_config_readonly(self.game_dir, False, self.log_widget) 634 | 635 | def _platform_label(self) -> str: 636 | system = platform.system() 637 | machine = platform.machine() 638 | 639 | if system == "Linux": 640 | distro = None 641 | try: 642 | os_release = platform.freedesktop_os_release() 643 | name = os_release.get("NAME") 644 | version = os_release.get("VERSION") or os_release.get("VERSION_ID") 645 | distro = " ".join(part for part in (name, version) if part) 646 | except Exception: 647 | distro = None 648 | 649 | if distro and machine: 650 | return f"{system} - {distro} ({machine})" 651 | if distro: 652 | return f"{system} - {distro}" 653 | if machine: 654 | return f"{system} ({machine})" 655 | return system or "Unknown platform" 656 | 657 | release = platform.release() 658 | if system and release and machine: 659 | return f"{system} {release} ({machine})" 660 | if system and release: 661 | return f"{system} {release}" 662 | if system and machine: 663 | return f"{system} ({machine})" 664 | if system: 665 | return system 666 | return "Unknown platform" 667 | 668 | def _build_log_payload(self, log_text: str) -> str: 669 | header = f"PatchOpsIII {APP_VERSION} - {self._platform_label()} logs:" 670 | body = log_text if log_text else "(no log entries found)" 671 | return f"{header}\n```\n{body}\n```" 672 | 673 | def copy_logs_to_clipboard(self): 674 | log_path = get_log_file_path() 675 | if not os.path.exists(log_path): 676 | log_content = "" 677 | write_log(f"Log file not found at {log_path}. Copying empty log header.", "Warning", self.log_widget) 678 | else: 679 | try: 680 | with open(log_path, "r", encoding="utf-8") as f: 681 | log_content = f.read().strip() 682 | except Exception as exc: 683 | write_log(f"Unable to read log file: {exc}", "Error", self.log_widget) 684 | QMessageBox.warning(self, "Copy Logs", f"Could not read logs from {log_path}.\nError: {exc}") 685 | return 686 | 687 | clipboard = QGuiApplication.clipboard() 688 | payload = self._build_log_payload(log_content) 689 | if clipboard: 690 | clipboard.setText(payload) 691 | else: 692 | write_log("Clipboard not available; unable to copy logs.", "Error", self.log_widget) 693 | QMessageBox.warning(self, "Copy Logs", "Clipboard not available. Please try again.") 694 | return 695 | 696 | write_log("Logs copied to clipboard.", "Success", self.log_widget) 697 | msg = QMessageBox(self) 698 | msg.setWindowTitle("Logs Copied") 699 | msg.setTextFormat(Qt.RichText) 700 | msg.setText( 701 | "Logs copied to clipboard.

" 702 | 'Have an issue?:
' 703 | 'Submit it here.' 704 | ) 705 | msg.setStandardButtons(QMessageBox.Ok) 706 | msg.exec() 707 | --------------------------------------------------------------------------------