├── 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 | 
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 | [](https://github.com/boggedbrush/PatchOpsIII/releases)
4 | [](https://github.com/boggedbrush/PatchOpsIII/releases)
5 | [](https://github.com/boggedbrush/PatchOpsIII/stargazers)
6 | [](https://github.com/boggedbrush/PatchOpsIII/issues)
7 | [](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 | 
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 |
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 | [](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 |
--------------------------------------------------------------------------------