diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
| commit | 7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch) | |
| tree | 27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games | |
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem,
Proton/umu-run integration, and Flatpak packaging.
Key features:
- FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak)
- Proton/GE-Proton/umu-run launcher with env var forwarding
- Flatpak support (sandbox-aware VFS, NXM handler, umu-run)
- Wine prefix management UI
- Case-insensitive path resolution for Linux filesystems
- QSettings-safe INI handling (avoids Bethesda INI corruption)
- Portable instance support with auto-generated launcher scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games')
135 files changed, 12867 insertions, 0 deletions
diff --git a/libs/basic_games/.github/workflows/build.yml b/libs/basic_games/.github/workflows/build.yml new file mode 100644 index 0000000..ef00467 --- /dev/null +++ b/libs/basic_games/.github/workflows/build.yml @@ -0,0 +1,44 @@ +name: Build Basic Games Plugin + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build: + runs-on: windows-2022 + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set environmental variables + shell: bash + run: | + echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> $GITHUB_ENV + + - uses: actions/checkout@v4 + with: + path: build + + - name: Configure Basic Games Plugin build + working-directory: ${{ github.workspace }}/build + shell: pwsh + run: | + cmake --preset vs2022-windows "-DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install" "-DVCPKG_MANIFEST_FEATURES=standalone" + + - name: Build Basic Games Plugin + working-directory: ${{ github.workspace }}/build + run: cmake --build vsbuild --config RelWithDebInfo + + - name: Install Basic Games Plugin + working-directory: ${{ github.workspace }}/build + run: cmake --install vsbuild --config RelWithDebInfo + + - name: Upload Basic Games Plugin artifact + uses: actions/upload-artifact@master + with: + name: basic_games + path: ${{ github.workspace }}/install/bin/plugins diff --git a/libs/basic_games/.github/workflows/linters.yml b/libs/basic_games/.github/workflows/linters.yml new file mode 100644 index 0000000..711a3a7 --- /dev/null +++ b/libs/basic_games/.github/workflows/linters.yml @@ -0,0 +1,24 @@ +name: linters + +on: [push, pull_request] + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: "basic_games" + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.12 + - uses: abatilo/actions-poetry@v2 + - name: Install + run: | + cd basic_games + poetry --no-root install + - name: Lint + run: | + cd basic_games + poetry run poe lint diff --git a/libs/basic_games/.gitignore b/libs/basic_games/.gitignore new file mode 100644 index 0000000..910d2b5 --- /dev/null +++ b/libs/basic_games/.gitignore @@ -0,0 +1,12 @@ +.mypy_cache +__pycache__ +.venv +venv +.tox +.vscode +.idea + +# Mob / Umbrella +mob.log +vsbuild +lib diff --git a/libs/basic_games/.pre-commit-config.yaml b/libs/basic_games/.pre-commit-config.yaml new file mode 100644 index 0000000..ae8a99a --- /dev/null +++ b/libs/basic_games/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + - id: check-case-conflict + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.10 # must match pyproject.toml + hooks: + - id: ruff + args: [--extend-select, I, --fix] + - id: ruff-format + +ci: + autofix_commit_msg: "[pre-commit.ci] Auto fixes from pre-commit.com hooks." + autofix_prs: true + autoupdate_commit_msg: "[pre-commit.ci] Pre-commit autoupdate." + autoupdate_schedule: quarterly + submodules: false diff --git a/libs/basic_games/CMakeLists.txt b/libs/basic_games/CMakeLists.txt new file mode 100644 index 0000000..288c2a4 --- /dev/null +++ b/libs/basic_games/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.16) + +project(basic_games LANGUAGES NONE) + +find_package(mo2-cmake CONFIG REQUIRED) + +add_custom_target(basic_games ALL) +mo2_configure_python(basic_games MODULE TRANSLATIONS OFF) diff --git a/libs/basic_games/CMakePresets.json b/libs/basic_games/CMakePresets.json new file mode 100644 index 0000000..acc6d97 --- /dev/null +++ b/libs/basic_games/CMakePresets.json @@ -0,0 +1,17 @@ +{ + "configurePresets": [ + { + "binaryDir": "${sourceDir}/vsbuild", + "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "generator": "Visual Studio 17 2022", + "name": "vs2022-windows" + } + ], + "buildPresets": [ + { + "name": "vs2022-windows", + "configurePreset": "vs2022-windows" + } + ], + "version": 4 +} diff --git a/libs/basic_games/LICENSE b/libs/basic_games/LICENSE new file mode 100644 index 0000000..45ca39c --- /dev/null +++ b/libs/basic_games/LICENSE @@ -0,0 +1,7 @@ +Copyright 2020 Holt59 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libs/basic_games/README.md b/libs/basic_games/README.md new file mode 100644 index 0000000..2c0e98b --- /dev/null +++ b/libs/basic_games/README.md @@ -0,0 +1,233 @@ +# Mod Organizer 2 - Simple Games Plugin + +Mod Organizer 2 meta-plugin to make creating game plugins easier and faster. + +## Why? + +In order to create a MO2 game plugin, one must implement the `IPluginGame` interface. +This interface was initially designed for Bethesda games such as the Elder Scrolls or +Fallout series and thus contains a lot of things that are irrelevant for most games. + +The goal of this meta-plugin is to allow creating game plugins for "basic" games by +providing a very simple python class. + +## How to install? + +Download the archive for your MO2 version and extract it directly into your MO2 `plugins` folder. + +- Mod Organizer **2.3.2**: [Download](https://github.com/ModOrganizer2/modorganizer-basic_games/releases/download/v0.0.3/basic_games-0.0.3.zip) + and extract in your `plugins/` folder (see below). +- Mod Organizer **2.4**: Basic games is included in Mod Organizer 2.4. + - If you want to use new game plugins that have not been included in the + release, [download the latest archive](https://github.com/ModOrganizer2/modorganizer-basic_games/archive/master.zip) and extract the files + in the existing `basic_games` folder, overwriting existing files. + +**Important:** Extract the *folder* in your `plugins` folder, not the individual files. Your +`plugins` folder should look like this: + +```text +dlls/ +plugins/ + data/ + basic_games/ + games/ + __init__.py + ... + __init__.py + basic_game.py + ... + bsa_extractor.dll + ... +ModOrganizer.exe +``` + +You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic_games`). + +## Supported games + +| Game | Author | File | Extras | +|------|--------|------|--------| +| The Binding of Isaac: Rebirth — [STEAM](https://store.steampowered.com/app/250900/The_Binding_of_Isaac_Rebirth/) |[EzioTheDeadPoet](https://github.com/EzioTheDeadPoet)|[game_thebindingofisaacrebirth.py](games/game_thebindingofisaacrebirth.py)|<ul><li>profile specific ini file</li></ul>| +| Control — [STEAM](https://store.steampowered.com/app/870780/Control_Ultimate_Edition/) / [GOG](https://www.gog.com/game/control_ultimate_edition) / [EGS](https://www.epicgames.com/store/p/control) | [Zash](https://github.com/ZashIn) | [game_control.py](games/game_control.py) | | +| Darkest Dungeon — [GOG](https://www.gog.com/game/darkest_dungeon) / [STEAM](https://store.steampowered.com/app/262060/Darkest_Dungeon/) | [erri120](https://github.com/erri120) | [game_darkestdungeon.py](games/game_darkestdungeon.py) | <ul><li>save slot parsing</li><li>mod data checker</li></ul> | +| Dark Messiah of Might & Magic — [STEAM](https://store.steampowered.com/app/2100/Dark_Messiah_of_Might__Magic/) | [Holt59](https://github.com/holt59/) | [game_darkmessiahofmightandmagic.py](games/game_darkmessiahofmightandmagic.py) | <ul><li>save game preview</li></ul> | +| Dark Souls — [STEAM](https://store.steampowered.com/app/211420/DARK_SOULS_Prepare_To_Die_Edition/) | [Holt59](https://github.com/holt59/) | [game_darksouls.py](games/game_darkestdungeon.py) | | +| Divinity: Original Sin (Classic) — [STEAM](https://store.steampowered.com/app/230230/Divinity_Original_Sin_Classic/) | [LostDragonist](https://github.com/LostDragonist/) | [game_divinityoriginalsin.py](games/game_divinityoriginalsin.py) | <ul><li>save game preview</li></ul> | +| Divinity: Original Sin (Enhanced Edition) — [STEAM](https://store.steampowered.com/app/373420/Divinity_Original_Sin__Enhanced_Edition/) | [LostDragonist](https://github.com/LostDragonist/) | [game_divinityoriginalsinee.py](games/game_divinityoriginalsinee.py) | <ul><li>save game preview</li><li>mod data checker</li></ul> | +| Dragon's Dogma: Dark Arisen — [GOG](https://www.gog.com/game/dragons_dogma_dark_arisen) / [STEAM](https://store.steampowered.com/app/367500/Dragons_Dogma_Dark_Arisen/) | [EzioTheDeadPoet](https://github.com/EzioTheDeadPoet) | [game_dragonsdogmadarkarisen.py](games/game_dragonsdogmadarkarisen.py) | | +| Dungeon Siege II — [GOG](https://www.gog.com/game/dungeon_siege_collection) / [STEAM](https://store.steampowered.com/app/39200/Dungeon_Siege_II/) | [Holt59](https://github.com/holt59/) | [game_dungeonsiege2.py](games/game_dungeonsiege2.py) | <ul><li>mod data checker</li></ul> | +| Kingdom Come: Deliverance — [GOG](https://www.gog.com/game/kingdom_come_deliverance) / [STEAM](https://store.steampowered.com/app/379430/Kingdom_Come_Deliverance/) / [Epic](https://store.epicgames.com/p/kingdom-come-deliverance) | [Silencer711](https://github.com/Silencer711) | [game_kingdomcomedeliverance.py](games/game_kingdomcomedeliverance.py) | <ul><li>profile specific cfg files</li></ul>| +| METAL GEAR SOLID 2: Sons of Liberty — [STEAM](https://store.steampowered.com/app/2131640/METAL_GEAR_SOLID_2_Sons_of_Liberty__Master_Collection_Version/)|[AkiraJkr](https://github.com/AkiraJkr)|[game_metalgearsolid2mc.py](games/game_metalgearsolid2mc.py)| | +| METAL GEAR SOLID 3: Snake Eater — [STEAM](https://store.steampowered.com/app/2131650/METAL_GEAR_SOLID_3_Snake_Eater__Master_Collection_Version/)|[AkiraJkr](https://github.com/AkiraJkr)|[game_metalgearsolid3mc.py](games/game_metalgearsolid3mc.py)| | +| Mirror's Edge — [GOG](https://www.gog.com/game/mirrors_edge) / [STEAM](https://store.steampowered.com/app/17410/Mirrors_Edge)|[EzioTheDeadPoet](https://eziothedeadpoet.github.io/AboutMe/)|[game_mirrorsedge.py](games/game_mirrorsedge.py)| | +| Mount & Blade II: Bannerlord — [GOG](https://www.gog.com/game/mount_blade_ii_bannerlord) / [STEAM](https://store.steampowered.com/app/261550/Mount__Blade_II_Bannerlord/) | [Holt59](https://github.com/holt59/) | [game_mountandblade2.py](games/game_mountandblade2.py) | <ul><li>mod data checker</li></ul> | +| Need for Speed: High Stakes | [uwx](https://github.com/uwx) | [game_nfshs.py](games/game_nfshs.py) | | +| No Man's Sky - [GOG](https://www.gog.com/game/no_mans_sky) / [Steam](https://store.steampowered.com/app/275850/No_Mans_Sky/)|[EzioTheDeadPoet](https://eziothedeadpoet.github.io/AboutMe/)|[game_nomanssky.py](games/game_nomanssky.py)| | +| S.T.A.L.K.E.R. Anomaly — [MOD](https://www.stalker-anomaly.com/) | [Qudix](https://github.com/Qudix) | [game_stalkeranomaly.py](games/game_stalkeranomaly.py) | <ul><li>mod data checker</li></ul> | +| Stardew Valley — [GOG](https://www.gog.com/game/stardew_valley) / [STEAM](https://store.steampowered.com/app/413150/Stardew_Valley/) | [Syer10](https://github.com/Syer10), [Holt59](https://github.com/holt59/) | [game_stardewvalley.py](games/game_stardewvalley.py) | <ul><li>mod data checker</li></ul> | +| STAR WARS™ Empire at War: Gold Pack - [GOG](https://www.gog.com/game/star_wars_empire_at_war_gold_pack) / [STEAM](https://store.steampowered.com/app/32470/) | [erri120](https://github.com/erri120) | <ul><li>Empire at War: [game_starwars-empire-at-war.py](games/game_starwars-empire-at-war.py)</li><li>Force of Corruption: [game_starwars-empire-at-war-foc.py](games/game_starwars-empire-at-war-foc.py)</li></ul> | | +| Subnautica — [STEAM](https://store.steampowered.com/app/264710/) / [Epic](https://store.epicgames.com/p/subnautica) | [dekart811](https://github.com/dekart811), [Zash](https://github.com/ZashIn) | [game_subnautica.py](games/game_subnautica.py) | <ul><li>mod data checker</li><li>save game preview</li></ul> | +| Subnautica: Below Zero — [STEAM](https://store.steampowered.com/app/848450/) | [dekart811](https://github.com/dekart811), [Zash](https://github.com/ZashIn) | [game_subnautica-below-zero.py](games/game_subnautica-below-zero.py) | <ul><li>mod data checker</li><li>save game preview</li></ul> | +| Train Simulator Classic — [STEAM](https://store.steampowered.com/app/24010/) | [Ryan Young](https://github.com/YoRyan) | [game_trainsimulator.py](games/game_trainsimulator.py) | | +| Valheim — [STEAM](https://store.steampowered.com/app/892970/Valheim/) | [Zash](https://github.com/ZashIn) | [game_valheim.py](games/game_valheim.py) | <ul><li>mod data checker</li><li>overwrite config sync</li><li>save game support (no preview)</li></ul> | +| Test Drive Unlimited | [uwx](https://github.com/uwx) | [game_tdu.py](games/game_tdu.py) | | +| Test Drive Unlimited 2 — [STEAM](https://steamcommunity.com/app/9930/) | [uwx](https://github.com/uwx) | [game_tdu2.py](games/game_tdu2.py) | | +| The Witcher: Enhanced Edition - [GOG](https://www.gog.com/game/the_witcher) / [STEAM](https://store.steampowered.com/app/20900/The_Witcher_Enhanced_Edition_Directors_Cut/) | [erri120](https://github.com/erri120) | [game_witcher1.py](games/game_witcher1.py) | <ul><li>save game parsing (no preview)</li></ul> | +| The Witcher 3: Wild Hunt — [GOG](https://www.gog.com/game/the_witcher_3_wild_hunt) / [STEAM](https://store.steampowered.com/app/292030/The_Witcher_3_Wild_Hunt/) | [Holt59](https://github.com/holt59/) | [game_witcher3.py](games/game_witcher3.py) | <ul><li>save game preview</li></ul> | +| Tony Hawk's Pro Skater 3 | [uwx](https://github.com/uwx) | [game_thps3.py](games/game_thps3.py) | | +| Tony Hawk's Pro Skater 4 | [uwx](https://github.com/uwx) | [game_thps4.py](games/game_thps4.py) | | +| Tony Hawk's Underground | [uwx](https://github.com/uwx) | [game_thug.py](games/game_thug.py) | | +| Tony Hawk's Underground 2 | [uwx](https://github.com/uwx) | [game_thug2.py](games/game_thug2.py) | | +| Trackmania United Forever — [STEAM](https://store.steampowered.com/app/7200/Trackmania_United_Forever/) | [uwx](https://github.com/uwx) | [game_tmuf.py](games/game_tmuf.py) | | +| Yu-Gi-Oh! Master Duel — [STEAM](https://store.steampowered.com/app/1449850/) | [The Conceptionist](https://github.com/the-conceptionist) & [uwx](https://github.com/uwx) | [game_masterduel.py](games/game_masterduel.py) | | +| Zeus and Poseidon — [GOG](https://www.gog.com/game/zeus_poseidon) / [STEAM](https://store.steampowered.com/app/566050/Zeus__Poseidon/) | [Holt59](https://github.com/holt59/) | [game_zeusandpoiseidon.py](games/game_zeusandpoiseidon.py) | <ul><li>mod data checker</li></ul> | + +## How to add a new game? + +You can create a plugin by providing a python class in the `games` folder. + +**Note:** If your game plugin does not load properly, you should set the log level +to debug and look at the `mo_interface.log` file. + +You need to create a class that inherits `BasicGame` and put it in a `game_XX.py` in `games`. +Below is an example for The Witcher 3 (see also [games/game_witcher3.py](games/game_witcher3.py)): + +```python +from PyQt6.QtCore import QDir +from ..basic_game import BasicGame + + +class Witcher3Game(BasicGame): + + Name = "Witcher 3 Support Plugin" + Author = "Holt59" + Version = "1.0.0a" + + GameName = "The Witcher 3" + GameShortName = "witcher3" + GameBinary = "bin/x64/witcher3.exe" + GameDataPath = "Mods" + GameSaveExtension = "sav" + GameSteamId = 292030 + + def savesDirectory(self): + return QDir(self.documentsDirectory().absoluteFilePath("gamesaves")) +``` + +`BasicGame` inherits `IPluginGame` so you can override methods if you need to. +Each attribute you provide corresponds to a method (e.g., `Version` corresponds +to the `version` method, see the table below). If you override the method, you do +not have to provide the attribute: + +```python +from PyQt6.QtCore import QDir +from ..basic_game import BasicGame + +import mobase + + +class Witcher3Game(BasicGame): + + Name = "Witcher 3 Support Plugin" + Author = "Holt59" + + GameName = "The Witcher 3" + GameShortName = "witcher3" + GameBinary = "bin/x64/witcher3.exe" + GameDataPath = "Mods" + GameSaveExtension = "sav" + GameSteamId = 292030 + + def version(self): + # Don't forget to import mobase! + return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.final) + + def savesDirectory(self): + return QDir(self.documentsDirectory().absoluteFilePath("gamesaves")) +``` + +### List of valid keys + +| Name | Description | `IPluginGame` method | Python | +|------|-------------|----------------------|--------| +| Name | Name of the plugin | `name` | `str` | +| Author | Author of the plugin | `author` | `str` | +| Version | Version of the plugin | `version` | `str` or `mobase.VersionInfo` | +| Description| Description (Optional) | `description` | `str` | +| GameName | Name of the game, as displayed by MO2 | `gameName` | `str` | +| GameShortName | Short name of the game | `gameShortName` | `str` | +| GameNexusName| Nexus name of the game (Optional, default to `GameShortName`) | `gameNexusName` | `str` | +| GameValidShortNames | Other valid short names (Optional) | `validShortNames` | `List[str]` or comma-separated list of values | +| GameNexusId | Nexus ID of the game (Optional) | `nexusGameID` | `str` or `int` | +| GameBinary | Name of the game executable, relative to the game path | `binaryName` | `str` | +| GameLauncher | Name of the game launcher, relative to the game path (Optional) | `getLauncherName` | `str` | +| GameDataPath | Name of the folder containing mods, relative to game folder| `dataDirectory` | | +| GameDocumentsDirectory | Documents directory (Optional) | `documentsDirectory` | `str` or `QDir` | +| GameIniFiles | Config files in documents, for profile specific config (Optional) | `iniFiles` | `str` or `List[str]` | +| GameSavesDirectory | Directory containing saves (Optional, default to `GameDocumentsDirectory`) | `savesDirectory` | `str` or `QDir` | +| GameSaveExtension | Save file extension (Optional) `savegameExtension` | `str` | +| GameSteamId | Steam ID of the game (Optional) | `steamAPPId` | `List[str]` or `str` or `int` | +| GameGogId | GOG ID of the game (Optional) | `gogAPPId` | `List[str]` or `str` or `int` | +| GameOriginManifestIds | Origin Manifest ID of the game (Optional) | `originManifestIds` | `List[str]` or `str` | +| GameOriginWatcherExecutables | Executables to watch for Origin DRM (Optional) | `originWatcherExecutables` | `List[str]` or `str` | +| GameEpicId | Epic ID (`AppName`) of the game (Optional) | `epicAPPId` | `List[str]` or `str` | +| GameEaDesktopId | EA Desktop ID of the game (Optional) | `eaDesktopContentId` | `List[str]` or `str` or `int` | + +You can use the following variables for `str`: + +- `%DOCUMENTS%` will be replaced by the standard *Documents* folder. +- `%GAME_PATH%` will be replaced by the path to the game folder. +- `%GAME_DOCUMENTS%` will be replaced by the value of `GameDocumentsDirectory`. + +## Extra features + +The meta-plugin provides some useful extra feature: + +1. **Automatic Steam, GOG, Origin, Epic Games and EA Desktop detection:** If you provide + Steam, GOG, Origin or Epic IDs for the game (via `GameSteamId`, `GameGogId`, + `GameOriginManifestIds`, `GameEpicId` or `GameEaDesktopId`), the game will be listed + in the list of available games when creating a new MO2 instance (if the game is + installed via Steam, GOG, Origin, Epic Games / Legendary or EA Desktop). +2. **Basic save game preview / metadata** (Python): If you can easily obtain a picture + (file) and/or metadata (like from json) for any saves, you can provide basic save-game + preview by using the `BasicGameSaveGameInfo`. See + [games/game_witcher3.py](games/game_witcher3.py) and + [games/game_bladeandsorcery.py](games/game_bladeandsorcery.py) for more details. +3. **Basic local save games** (Python): profile specific save games, as in [games/game_valheim.py](games/game_valheim.py). +4. **Basic mod data checker** (Python): + Check and fix different mod archive layouts for an automatic installation with the proper + file structure, using simple (glob) patterns via `BasicModDataChecker`. + See [games/game_valheim.py](games/game_valheim.py) and [game_subnautica.py](games/game_subnautica.py) for an example. + +Game IDs can be found here: + +- For Steam on [Steam Database](https://steamdb.info/) +- For GOG on [GOG Database](https://www.gogdb.org/) +- For Origin from `C:\ProgramData\Origin\LocalContent` (.mfst files) +- For Epic Games (`AppName`) from: + - `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests\` (.item files) + - or `C:\ProgramData\Epic\EpicGamesLauncher\UnrealEngineLauncher\LauncherInstalled.dat` + - or [Unofficial EGS ID DB](https://erri120.github.io/egs-db/) +- For Legendary (alt. Epic launcher) via command `legendary list-games` + or from: `%USERPROFILE%\.config\legendary\installed.json` +- For EA Desktop from `<EA Games install location>\<game title>\__Installer\installerdata.xml` + +## Contribute + +We recommend using a dedicated Python environment to write a new basic game plugins. + +1. Install the required version of Python --- Currently Python 3.11 (MO2 2.5). +2. Remove the repository at `${MO2_INSTALL}/plugins/basic_games`. +3. Clone this repository at the location of the old plugin ( + `${MO2_INSTALL}/plugins/basic_games`). +4. Place yourself inside the cloned folder and: + + ```bash + # create a virtual environment (recommended) + py -3.11 -m venv .\venv + .\venv\scripts\Activate.ps1 + + # "install" poetry and the development package + pip install poetry + poetry install + ``` diff --git a/libs/basic_games/__init__.py b/libs/basic_games/__init__.py new file mode 100644 index 0000000..dae0a24 --- /dev/null +++ b/libs/basic_games/__init__.py @@ -0,0 +1,96 @@ +# pyright: reportUnboundVariable=false + +import glob +import importlib +import os +import pathlib +import site +import sys +import typing + +from PyQt6.QtCore import qWarning + +from mobase import IPlugin + +from .basic_game import BasicGame +from .basic_game_ini import BasicIniGame + +site.addsitedir(os.path.join(os.path.dirname(__file__), "lib")) + + +BasicGame.setup() + + +def createPlugins(): + # List of game class from python: + game_plugins: typing.List[IPlugin] = [] + + # We are going to list all game plugins: + curpath = os.path.abspath(os.path.dirname(__file__)) + escaped_games_path = glob.escape(os.path.join(curpath, "games")) + + # List all the .ini files: + for file in glob.glob(os.path.join(escaped_games_path, "*.ini")): + game_plugins.append(BasicIniGame(file)) + + # List all the python plugins: + for file in glob.glob(os.path.join(escaped_games_path, "*.py")): + module_p = os.path.relpath(file, os.path.join(curpath, "games")) + if module_p == "__init__.py": + continue + + if module_p == "game_stalkeranomaly.py": + try: + import lzokay # type: ignore # noqa: F401 + except Exception: + # Optional dependency not available on this host. + continue + + # Import the module: + try: + module = importlib.import_module(".games." + module_p[:-3], __package__) + except ImportError as e: + print("Failed to import module {}: {}".format(module_p, e), file=sys.stderr) + continue + except Exception as e: + print("Failed to import module {}: {}".format(module_p, e), file=sys.stderr) + continue + + # Lookup game plugins: + for name in dir(module): + if hasattr(module, name): + obj = getattr(module, name) + if ( + isinstance(obj, type) + and issubclass(obj, BasicGame) + and obj is not BasicGame + ): + try: + game_plugins.append(obj()) + except Exception as e: + print( + "Failed to instantiate {}: {}".format(name, e), + file=sys.stderr, + ) + for path in pathlib.Path(escaped_games_path).rglob("plugins/__init__.py"): + module_path = "." + os.path.relpath(path.parent, curpath).replace(os.sep, ".") + try: + module = importlib.import_module(module_path, __package__) + if hasattr(module, "createPlugins") and callable(module.createPlugins): + try: + plugins: typing.Any = module.createPlugins() + for item in plugins: + if isinstance(item, IPlugin): + game_plugins.append(item) + except TypeError: + pass + if hasattr(module, "createPlugin") and callable(module.createPlugin): + plugin = module.createPlugin() + if isinstance(plugin, IPlugin): + game_plugins.append(plugin) + except ImportError as e: + qWarning(f"Error importing module {module_path}: {e}") + except Exception as e: + qWarning(f"Error calling function createPlugin(s) in {module_path}: {e}") + + return game_plugins diff --git a/libs/basic_games/basic_features/__init__.py b/libs/basic_games/basic_features/__init__.py new file mode 100644 index 0000000..9326dd9 --- /dev/null +++ b/libs/basic_games/basic_features/__init__.py @@ -0,0 +1,10 @@ +from .basic_local_savegames import BasicLocalSavegames +from .basic_mod_data_checker import BasicModDataChecker, GlobPatterns +from .basic_save_game_info import BasicGameSaveGameInfo + +__all__ = [ + "BasicModDataChecker", + "BasicGameSaveGameInfo", + "GlobPatterns", + "BasicLocalSavegames", +] diff --git a/libs/basic_games/basic_features/basic_local_savegames.py b/libs/basic_games/basic_features/basic_local_savegames.py new file mode 100644 index 0000000..e0991f8 --- /dev/null +++ b/libs/basic_games/basic_features/basic_local_savegames.py @@ -0,0 +1,27 @@ +from PyQt6.QtCore import QDir + +import mobase + + +class BasicLocalSavegames(mobase.LocalSavegames): + _game: mobase.IPluginGame + + def __init__(self, game: mobase.IPluginGame): + super().__init__() + self._game = game + + def game_save_dir(self) -> str: + return self._game.savesDirectory().absolutePath() + + def mappings(self, profile_save_dir: QDir): + return [ + mobase.Mapping( + source=profile_save_dir.absolutePath(), + destination=self.game_save_dir(), + is_directory=True, + create_target=True, + ) + ] + + def prepareProfile(self, profile: mobase.IProfile) -> bool: + return profile.localSavesEnabled() diff --git a/libs/basic_games/basic_features/basic_mod_data_checker.py b/libs/basic_games/basic_features/basic_mod_data_checker.py new file mode 100644 index 0000000..1d061eb --- /dev/null +++ b/libs/basic_games/basic_features/basic_mod_data_checker.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import fnmatch +import re +from dataclasses import dataclass, field +from typing import Iterable, Literal + +import mobase + +from .utils import is_directory + + +class OptionalRegexPattern: + _pattern: re.Pattern[str] | None + + def __init__(self, globs: Iterable[str] | None) -> None: + if globs is None: + self._pattern = None + else: + self._pattern = OptionalRegexPattern.regex_from_glob_list(globs) + + @staticmethod + def regex_from_glob_list(glob_list: Iterable[str]) -> re.Pattern[str]: + """ + Returns a regex pattern form a list of glob patterns. + + Every pattern has a capturing group, so that `match.lastindex - 1` will + give the `glob_list` index. + """ + return re.compile( + "|".join(f"({fnmatch.translate(f)})" for f in glob_list), re.I + ) + + def match(self, value: str) -> bool: + if self._pattern is None: + return False + return bool(self._pattern.match(value)) + + +class RegexPatterns: + """ + Regex patterns for validation in `BasicModDataChecker`. + """ + + def __init__(self, globs: GlobPatterns) -> None: + self.unfold = OptionalRegexPattern(globs.unfold) + self.delete = OptionalRegexPattern(globs.delete) + self.valid = OptionalRegexPattern(globs.valid) + + self.move = { + key: re.compile(fnmatch.translate(key), re.I) for key in globs.move + } + self.ignore = OptionalRegexPattern(globs.ignore) + + def move_match(self, value: str) -> str | None: + """ + Retrieve the first move patterns that matches the given value, or None if no + move matches. + """ + for key, pattern in self.move.items(): + if pattern.match(value): + return key + return None + + +def _merge_list(l1: list[str] | None, l2: list[str] | None) -> list[str] | None: + if l1 is None and l2 is None: + return None + + return (l1 or []) + (l2 or []) + + +@dataclass(frozen=True, unsafe_hash=True) +class GlobPatterns: + """ + See: `BasicModDataChecker` + """ + + unfold: list[str] | None = None + valid: list[str] | None = None + delete: list[str] | None = None + move: dict[str, str] = field(default_factory=dict[str, str]) + ignore: list[str] | None = None + + def merge( + self, other: GlobPatterns, mode: Literal["merge", "replace"] = "replace" + ) -> GlobPatterns: + """ + Construct a new GlobPatterns by merging the current one with the given one. + + There are two different modes: + - 'merge': In this mode, unfold/valid/delete are concatenated and move + will contain the union of key from self and other, with values from other + overriding common keys. + - 'replace': The merged object will contains attributes from other, except + for None attributes taken from self. + + Args: + other: Other patterns to "merge" with this one. + mode: Merge mode. + + Returns: + A new glob pattern representing the merge of this one with other. + """ + if mode == "merge": + return GlobPatterns( + unfold=_merge_list(self.unfold, other.unfold), + valid=_merge_list(self.valid, other.valid), + delete=_merge_list(self.delete, other.delete), + move=self.move | other.move, + ignore=_merge_list(self.ignore, other.ignore), + ) + else: + return GlobPatterns( + unfold=other.unfold or self.unfold, + valid=other.valid or self.valid, + delete=other.delete or self.delete, + move=other.move or self.move, + ignore=other.ignore or self.ignore, + ) + + +class BasicModDataChecker(mobase.ModDataChecker): + """Game feature that is used to check and fix the content of a data tree + via simple file definitions. + + The file definitions support glob pattern (without subfolders) and are + checked and fixed in definition order of the `file_patterns` dict. + + Args: + file_patterns (optional): A GlobPatterns object, with the following attributes: + ignore: [ "list of files and folders to ignore." ] + # Check result: unchanged + unfold: [ "list of folders to unfold" ], + # (remove and move contents to parent), after being checked and + # fixed recursively. + # Check result: `mobase.ModDataChecker.VALID`. + + valid: [ "list of files and folders in the right path." ], + # Check result: `mobase.ModDataChecker.VALID`. + + delete: [ "list of files/folders to delete." ], + # Check result: `mobase.ModDataChecker.FIXABLE`. + + move: {"Files/folders to move": "target path"} + # If the path ends with `/` or `\\`, the entry will be inserted + # in the corresponding directory instead of replacing it. + # Check result: `mobase.ModDataChecker.FIXABLE`. + + Example: + + BasicModDataChecker( + GlobPatterns( + valid=["valid_folder", "*.ext1"] + move={"*.ext2": "path/to/target_folder/"} + ) + ) + + See Also: + `mobase.IFileTree.move` for the `"move"` target path specs. + """ + + _file_patterns: GlobPatterns + """Private `file_patterns`, updated together with `._regex` and `._move_targets`.""" + + _regex_patterns: RegexPatterns + """The regex patterns derived from the file (glob) patterns.""" + + def __init__(self, file_patterns: GlobPatterns | None = None): + super().__init__() + + self._file_patterns = file_patterns or GlobPatterns() + self._regex_patterns = RegexPatterns(self._file_patterns) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + + if rp.ignore.match(name): + continue + if rp.unfold.match(name): + if is_directory(entry): + status = self.dataLooksValid(entry) + else: + status = mobase.ModDataChecker.INVALID + break + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif rp.delete.match(name) or rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + else: + status = mobase.ModDataChecker.INVALID + break + return status + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + rp = self._regex_patterns + for entry in list(filetree): + name = entry.name() + + if rp.ignore.match(name): + continue + # unfold first - if this match, entry is a directory (checked in + # dataLooksValid) + if rp.unfold.match(name): + assert is_directory(entry) + filetree.merge(entry) + entry.detach() + + elif rp.valid.match(name): + continue + + elif rp.delete.match(name): + entry.detach() + + elif (move_key := rp.move_match(name)) is not None: + target = self._file_patterns.move[move_key] + filetree.move(entry, target) + + return filetree diff --git a/libs/basic_games/basic_features/basic_save_game_info.py b/libs/basic_games/basic_features/basic_save_game_info.py new file mode 100644 index 0000000..436e724 --- /dev/null +++ b/libs/basic_games/basic_features/basic_save_game_info.py @@ -0,0 +1,226 @@ +# -*- encoding: utf-8 -*- + +import sys +from collections.abc import Mapping +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Self, Sequence + +from PyQt6.QtCore import QDateTime, QLocale, Qt +from PyQt6.QtGui import QImage, QPixmap +from PyQt6.QtWidgets import QFormLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + +import mobase + + +def format_date(date_time: QDateTime | datetime | str, format_str: str | None = None): + """Default format for date and time in the `BasicGameSaveGameInfoWidget`. + + Args: + date_time: either a `QDateTime`/`datetime` or a string together with + a `format_str`. + format_str (optional): date/time format string (see `QDateTime.fromString`). + + Returns: + Date and time in short locale format. + """ + if isinstance(date_time, str): + date_time = QDateTime.fromString(date_time, format_str) + return QLocale.system().toString(date_time, QLocale.FormatType.ShortFormat) + + +class BasicGameSaveGame(mobase.ISaveGame): + def __init__(self, filepath: Path): + super().__init__() + self._filepath = filepath + + def getFilepath(self) -> str: + return self._filepath.as_posix() + + def getName(self) -> str: + return self._filepath.name + + def getCreationTime(self): + return QDateTime.fromSecsSinceEpoch(int(self._filepath.stat().st_mtime)) + + def getSaveGroupIdentifier(self) -> str: + return "" + + def allFiles(self) -> list[str]: + return [self.getFilepath()] + + +def get_filedate_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + """Returns saves file date as the metadata for `BasicGameSaveGameInfoWidget`.""" + return {"File Date:": format_date(save.getCreationTime())} + + +class BasicGameSaveGameInfoWidget(mobase.ISaveGameInfoWidget): + """Save game info widget to display metadata and a preview.""" + + def __init__( + self, + parent: QWidget | None, + get_preview: ( + Callable[[Path], QPixmap | QImage | Path | str | None] | None + ) = lambda p: None, + get_metadata: ( + Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] | None + ) = get_filedate_metadata, + max_width: int = 320, + ): + """ + Args: + parent: parent widget + get_preview (optional): `callback(savegame_path)` returning the + saves preview image or the path to it. + get_metadata (optional): `callback(savegame_path, ISaveGame)` returning + the saves metadata. By default the saves file date is shown. + max_width (optional): The maximum widget and (scaled) preview width. + Defaults to 320. + """ + super().__init__(parent) + + def _no_preview(p: Path) -> None: + return None + + self._get_preview = get_preview or _no_preview + self._get_metadata = get_metadata or get_filedate_metadata + self._max_width = max_width or 320 + + layout = QVBoxLayout() + + # Metadata form + self._metadata_widget = QWidget() + self._metadata_widget.setMaximumWidth(self._max_width) + self._metadata_layout = form_layout = QFormLayout(self._metadata_widget) + form_layout.setContentsMargins(0, 0, 0, 0) + form_layout.setVerticalSpacing(2) + layout.addWidget(self._metadata_widget) + self._metadata_widget.hide() # Backwards compatibility (no metadata) + + # Preview (pixmap) + self._label = QLabel() + layout.addWidget(self._label) + self.setLayout(layout) + + self.setWindowFlags( + Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget + ) + + def setSave(self, save: mobase.ISaveGame): + save_path = Path(save.getFilepath()) + + # Clear previous + self.hide() + self._label.clear() + while self._metadata_layout.count(): + layoutItem = self._metadata_layout.takeAt(0) + if layoutItem is not None and (w := layoutItem.widget()): + w.deleteLater() + + # Retrieve the pixmap and metadata: + preview = self._get_preview(save_path) + pixmap = None + + # Set the preview pixmap if the preview file exits + if preview is not None: + if isinstance(preview, str): + preview = Path(preview) + if isinstance(preview, Path): + if preview.exists(): + pixmap = QPixmap(str(preview)) + else: + print( + f"Failed to retrieve the preview, file not found: {preview}", + file=sys.stderr, + ) + elif isinstance(preview, QImage): + pixmap = QPixmap.fromImage(preview) + else: + pixmap = preview + if pixmap and not pixmap.isNull(): + # Scale the pixmap and show it: + pixmap = pixmap.scaledToWidth(self._max_width) + self._label.setPixmap(pixmap) + self._label.show() + else: + self._label.hide() + pixmap = None + + # Add metadata, file date by default. + metadata = self._get_metadata(save_path, save) + if metadata: + for key, value in metadata.items(): + self._metadata_layout.addRow(*self._new_form_row(key, str(value))) + self._metadata_widget.show() + self._metadata_widget.setLayout(self._metadata_layout) + self._metadata_widget.adjustSize() + else: + self._metadata_widget.hide() + + if metadata or pixmap: + self.adjustSize() + self.show() + + def _new_form_row(self, label: str = "", field: str = ""): + qLabel = QLabel(text=label) + qLabel.setAlignment(Qt.AlignmentFlag.AlignTop) + qLabel.setStyleSheet("font: italic") + qLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + qField = QLabel(text=field) + qField.setWordWrap(True) + qField.setAlignment(Qt.AlignmentFlag.AlignTop) + qField.setStyleSheet("font: bold") + qField.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + return qLabel, qField + + def set_maximum_width(self, width: int): + self._max_width = width + self._metadata_widget.setMaximumWidth(width) + + +class BasicGameSaveGameInfo(mobase.SaveGameInfo): + _get_widget: Callable[[QWidget | None], mobase.ISaveGameInfoWidget | None] | None + + def __init__( + self, + get_preview: ( + Callable[[Path], QPixmap | QImage | Path | str | None] | None + ) = None, + get_metadata: ( + Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] | None + ) = None, + max_width: int = 0, + ): + """Args from: `BasicGameSaveGameInfoWidget`.""" + super().__init__() + self._get_widget = lambda parent: BasicGameSaveGameInfoWidget( + parent, get_preview, get_metadata, max_width + ) + + @classmethod + def with_widget( + cls, + widget: type[mobase.ISaveGameInfoWidget] | None, + ) -> Self: + """ + + Args: + widget: a custom `ISaveGameInfoWidget` instead of the default + `BasicGameSaveGameInfoWidget`. + """ + self = cls() + self._get_widget = lambda parent: widget(parent) if widget else None + return self + + def getMissingAssets(self, save: mobase.ISaveGame) -> dict[str, Sequence[str]]: + return {} + + def getSaveGameWidget( + self, parent: QWidget | None = None + ) -> mobase.ISaveGameInfoWidget | None: + if self._get_widget: + return self._get_widget(parent) + else: + return None diff --git a/libs/basic_games/basic_features/utils.py b/libs/basic_games/basic_features/utils.py new file mode 100644 index 0000000..b703901 --- /dev/null +++ b/libs/basic_games/basic_features/utils.py @@ -0,0 +1,7 @@ +from typing import TypeGuard + +import mobase + + +def is_directory(entry: mobase.FileTreeEntry) -> TypeGuard[mobase.IFileTree]: + return entry.isDir() diff --git a/libs/basic_games/basic_game.py b/libs/basic_games/basic_game.py new file mode 100644 index 0000000..e0f7ba5 --- /dev/null +++ b/libs/basic_games/basic_game.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +import shutil +import sys +from pathlib import Path +from typing import Callable, Generic, TypeVar + +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QMessageBox + +import mobase + +from .basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) + + +def replace_variables(value: str, game: BasicGame) -> str: + """Replace special paths in the given value.""" + + if value.find("%DOCUMENTS%") != -1: + value = value.replace( + "%DOCUMENTS%", + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ), + ) + if value.find("%USERPROFILE%") != -1: + value = value.replace( + "%USERPROFILE%", + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.HomeLocation + ), + ) + if value.find("%GAME_DOCUMENTS%") != -1: + value = value.replace( + "%GAME_DOCUMENTS%", game.documentsDirectory().absolutePath() + ) + if value.find("%GAME_PATH%") != -1: + value = value.replace("%GAME_PATH%", game.gameDirectory().absolutePath()) + + return value + + +_T = TypeVar("_T") + + +class BasicGameMapping(Generic[_T]): + # The game: + _game: "BasicGame" + + # Name of the attribute for exposure: + _exposed_name: str + + # Name of the internal method: + _internal_method_name: str + + # Required: + _required: bool + + # Callable returning a default value (if not required): + _default: Callable[["BasicGame"], _T] + + # Function to apply to the value: + _apply_fn: Callable[[_T | str], _T] | None + + def __init__( + self, + game: BasicGame, + exposed_name: str, + internal_method: str, + default: Callable[[BasicGame], _T] | None = None, + apply_fn: Callable[[_T | str], _T] | None = None, + ): + self._game = game + self._exposed_name = exposed_name + self._internal_method_name = internal_method + self._apply_fn = apply_fn + + if hasattr(game, self._exposed_name): + value = getattr(game, self._exposed_name) + + if self._apply_fn is not None: + try: + value = self._apply_fn(value) + except Exception as err: + raise ValueError( + "Basic game plugin from {} has an invalid {} property.".format( + game._fromName, # pyright: ignore[reportPrivateUsage] + self._exposed_name, + ) + ) from err + self._default = lambda game: value # type: ignore + elif default is not None: + self._default = default # type: ignore + elif getattr(game.__class__, self._internal_method_name) is getattr( + BasicGame, self._internal_method_name + ): + raise ValueError( + "Basic game plugin from {} is missing {} property.".format( + game._fromName, # pyright: ignore[reportPrivateUsage] + self._exposed_name, + ) + ) + + def get(self) -> _T: + """Return the value of this mapping.""" + value = self._default(self._game) # type: ignore + + if isinstance(value, str): + return replace_variables(value, self._game) # type: ignore + elif isinstance(value, QDir): + return QDir(replace_variables(value.path(), self._game)) # type: ignore + + # MO2 does not support Path anywhere so we always convert to str: + elif isinstance(value, Path): + return replace_variables(str(value), self._game) # type: ignore + + return value + + +class BasicGameOptionsMapping(BasicGameMapping[list[_T]]): + """ + Represents a game mappings for which multiple options are possible. The game + plugin is responsible to choose the right option depending on the context. + """ + + _index: int + + def __init__( + self, + game: BasicGame, + exposed_name: str, + internal_method: str, + default: Callable[[BasicGame], _T] | None = None, + apply_fn: Callable[[list[_T] | str], list[_T]] | None = None, + ): + super().__init__(game, exposed_name, internal_method, lambda g: [], apply_fn) + self._index = -1 + self._current_default = default + + def set_index(self, index: int): + """ + Set the index of the option to use. + + Args: + index: Index of the option to use. + """ + self._index = index + + def set_value(self, value: _T): + """ + Set the index corresponding of the given value. If the value is not present, + the index is set to -1. + + Args: + value: The value to set the index to. + """ + try: + self._index = self.get().index(value) + except ValueError: + self._index = -1 + + def has_value(self) -> bool: + """ + Check if a value was set for this options mapping. + + Returns: + True if a value was set, False otherwise. + """ + return self._index != -1 + + def current(self) -> _T: + values = self._default(self._game) # type: ignore + + if not values: + return self._current_default(self._game) # type: ignore + + if self._index == -1: + value = values[0] + else: + value = values[self._index] + + if isinstance(value, str): + return replace_variables(value, self._game) # type: ignore + elif isinstance(value, QDir): + return QDir(replace_variables(value.path(), self._game)) # type: ignore + + return value + + +class BasicGameMappings: + name: BasicGameMapping[str] + author: BasicGameMapping[str] + version: BasicGameMapping[mobase.VersionInfo] + description: BasicGameMapping[str] + gameName: BasicGameMapping[str] + gameShortName: BasicGameMapping[str] + gameNexusName: BasicGameMapping[str] + gameThunderstoreName: BasicGameMapping[str] + validShortNames: BasicGameMapping[list[str]] + nexusGameId: BasicGameMapping[int] + binaryName: BasicGameMapping[str] + launcherName: BasicGameMapping[str] + dataDirectory: BasicGameMapping[str] + documentsDirectory: BasicGameMapping[QDir] + iniFiles: BasicGameMapping[list[str]] + savesDirectory: BasicGameMapping[QDir] + savegameExtension: BasicGameMapping[str] + steamAPPId: BasicGameOptionsMapping[str] + gogAPPId: BasicGameOptionsMapping[str] + originManifestIds: BasicGameOptionsMapping[str] + originWatcherExecutables: BasicGameMapping[list[str]] + epicAPPId: BasicGameOptionsMapping[str] + eaDesktopContentId: BasicGameOptionsMapping[str] + supportURL: BasicGameMapping[str] + + @staticmethod + def _default_documents_directory(game: mobase.IPluginGame): + folders = [ + "{}/My Games/{}".format( + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ), + game.gameName(), + ), + "{}/{}".format( + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ), + game.gameName(), + ), + ] + for folder in folders: + qdir = QDir(folder) + if qdir.exists(): + return qdir + + return QDir() + + # Game mappings: + def __init__(self, game: BasicGame): + self._game = game + + self.name = BasicGameMapping(game, "Name", "name") + self.author = BasicGameMapping(game, "Author", "author") + self.version = BasicGameMapping( + game, + "Version", + "version", + apply_fn=lambda s: mobase.VersionInfo(s) if isinstance(s, str) else s, + ) + self.description = BasicGameMapping( + game, + "Description", + "description", + lambda g: "Adds basic support for game {}.".format(g.gameName()), + ) + self.gameName = BasicGameMapping(game, "GameName", "gameName") + self.gameShortName = BasicGameMapping(game, "GameShortName", "gameShortName") + self.gameNexusName = BasicGameMapping( + game, + "GameNexusName", + "gameNexusName", + default=lambda g: g.gameShortName(), + ) + self.gameThunderstoreName = BasicGameMapping( + game, + "GameThunderstoreName", + "gameThunderstoreName", + default=lambda g: "", + ) + self.validShortNames = BasicGameMapping( + game, + "GameValidShortNames", + "validShortNames", + default=lambda g: [], + apply_fn=lambda value: ( + [c.strip() for c in value.split(",")] # type: ignore + if isinstance(value, str) + else value + ), + ) + self.nexusGameId = BasicGameMapping( + game, "GameNexusId", "nexusGameID", default=lambda g: 0, apply_fn=int + ) + self.binaryName = BasicGameMapping(game, "GameBinary", "binaryName") + self.launcherName = BasicGameMapping( + game, + "GameLauncher", + "getLauncherName", + default=lambda g: "", + ) + self.dataDirectory = BasicGameMapping(game, "GameDataPath", "dataDirectory") + self.documentsDirectory = BasicGameMapping( + game, + "GameDocumentsDirectory", + "documentsDirectory", + apply_fn=lambda s: QDir(s) if isinstance(s, str) else s, + default=BasicGameMappings._default_documents_directory, + ) + self.iniFiles = BasicGameMapping( + game, + "GameIniFiles", + "iniFiles", + lambda g: [], + apply_fn=lambda value: ( + [c.strip() for c in value.split(",")] + if isinstance(value, str) + else value + ), + ) + self.savesDirectory = BasicGameMapping( + game, + "GameSavesDirectory", + "savesDirectory", + apply_fn=lambda s: QDir(s) if isinstance(s, str) else s, + default=lambda g: g.documentsDirectory(), + ) + self.savegameExtension = BasicGameMapping( + game, "GameSaveExtension", "savegameExtension", default=lambda g: "save" + ) + + # Convert Union[int, str, List[Union[int, str]]] to List[str]. + def ids_apply(v: list[int] | list[str] | int | str) -> list[str]: + """ + Convert various types to a list of string. If the given value is already a + list, returns a new list with all values converted to string, otherwise + returns a list with the value convert to a string as its only element. + """ + if isinstance(v, (int, str)): + v = [str(v)] + return [str(x) for x in v] + + self.steamAPPId = BasicGameOptionsMapping( + game, "GameSteamId", "steamAPPId", default=lambda g: "", apply_fn=ids_apply + ) + self.gogAPPId = BasicGameOptionsMapping( + game, "GameGogId", "gogAPPId", default=lambda g: "", apply_fn=ids_apply + ) + self.originManifestIds = BasicGameOptionsMapping( + game, + "GameOriginManifestIds", + "originManifestIds", + default=lambda g: "", + apply_fn=ids_apply, + ) + self.originWatcherExecutables = BasicGameMapping( + game, + "GameOriginWatcherExecutables", + "originWatcherExecutables", + apply_fn=lambda s: [s] if isinstance(s, str) else s, + default=lambda g: [], + ) + self.epicAPPId = BasicGameOptionsMapping( + game, "GameEpicId", "epicAPPId", default=lambda g: "", apply_fn=ids_apply + ) + self.eaDesktopContentId = BasicGameOptionsMapping( + game, + "GameEaDesktopId", + "eaDesktopContentId", + default=lambda g: "", + apply_fn=ids_apply, + ) + self.supportURL = BasicGameMapping( + game, "GameSupportURL", "supportURL", default=lambda g: "" + ) + + +class BasicGame(mobase.IPluginGame): + """This class implements some methods from mobase.IPluginGame + to make it easier to create game plugins without having to implement + all the methods of mobase.IPluginGame.""" + + # List of steam, GOG, origin and Epic games: + steam_games: dict[str, Path] + gog_games: dict[str, Path] + origin_games: dict[str, Path] + epic_games: dict[str, Path] + eadesktop_games: dict[str, Path] + + @staticmethod + def setup(): + from .eadesktop_utils import find_games as find_eadesktop_games + from .epic_utils import find_games as find_epic_games + from .gog_utils import find_games as find_gog_games + from .origin_utils import find_games as find_origin_games + from .steam_utils import find_games as find_steam_games + + errors: list[tuple[str, Exception]] = [] + BasicGame.steam_games = find_steam_games() + BasicGame.gog_games = find_gog_games() + BasicGame.origin_games = find_origin_games() + BasicGame.epic_games = find_epic_games(errors) + BasicGame.eadesktop_games = find_eadesktop_games(errors) + + if errors: + QMessageBox.critical( + None, + "Errors loading game list", + ( + "The following errors occurred while loading the list of available games:\n" + f"\n- {'\n\n- '.join('\n '.join(str(e) for e in messageError) for messageError in errors)}" + ), + ) + + # File containing the plugin: + _fromName: str + + # Organizer obtained in init: + _organizer: mobase.IOrganizer + + # Path to the game, as set by MO2: + _gamePath: str + + def __init__(self): + super(BasicGame, self).__init__() + + if not hasattr(self, "_fromName"): + self._fromName = self.__class__.__name__ + + self._gamePath = "" + + self._mappings: BasicGameMappings = BasicGameMappings(self) + + def _register_feature(self, feature: mobase.GameFeature) -> bool: + return self._organizer.gameFeatures().registerFeature(self, feature, 0, True) + + # Specific to BasicGame: + def is_steam(self) -> bool: + return self._mappings.steamAPPId.has_value() + + def is_gog(self) -> bool: + return self._mappings.gogAPPId.has_value() + + def is_origin(self) -> bool: + return self._mappings.originManifestIds.has_value() + + def is_epic(self) -> bool: + return self._mappings.epicAPPId.has_value() + + def is_eadesktop(self) -> bool: + return self._mappings.eaDesktopContentId.has_value() + + # IPlugin interface: + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + + self._register_feature(BasicGameSaveGameInfo()) + + if self._mappings.originWatcherExecutables.get(): + from .origin_utils import OriginWatcher + + self.origin_watcher = OriginWatcher( + self._mappings.originWatcherExecutables.get() + ) + if not self._organizer.onAboutToRun( + lambda appName: self.origin_watcher.spawn_origin_watcher() + ): + print("Failed to register onAboutToRun callback!", file=sys.stderr) + return False + if not self._organizer.onFinishedRun( + lambda appName, result: self.origin_watcher.stop_origin_watcher() + ): + print("Failed to register onFinishedRun callback!", file=sys.stderr) + return False + return True + + def name(self) -> str: + return self._mappings.name.get() + + def author(self) -> str: + return self._mappings.author.get() + + def description(self) -> str: + return self._mappings.description.get() + + def version(self) -> mobase.VersionInfo: + return self._mappings.version.get() + + def isActive(self) -> bool: + if not self._organizer.managedGame(): + return False + + # Note: self is self._organizer.managedGame() does not work: + return self.name() == self._organizer.managedGame().name() + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + # IPluginGame interface: + + def detectGame(self): + for steam_id in self._mappings.steamAPPId.get(): + if steam_id in BasicGame.steam_games: + self.setGamePath(BasicGame.steam_games[steam_id]) + return + + for gog_id in self._mappings.gogAPPId.get(): + if gog_id in BasicGame.gog_games: + self.setGamePath(BasicGame.gog_games[gog_id]) + return + + for origin_manifest_id in self._mappings.originManifestIds.get(): + if origin_manifest_id in BasicGame.origin_games: + self.setGamePath(BasicGame.origin_games[origin_manifest_id]) + return + + for epic_id in self._mappings.epicAPPId.get(): + if epic_id in BasicGame.epic_games: + self.setGamePath(BasicGame.epic_games[epic_id]) + return + + for eadesktop_content_id in self._mappings.eaDesktopContentId.get(): + if eadesktop_content_id in BasicGame.eadesktop_games: + self.setGamePath(BasicGame.eadesktop_games[eadesktop_content_id]) + return + + def gameName(self) -> str: + return self._mappings.gameName.get() + + def gameShortName(self) -> str: + return self._mappings.gameShortName.get() + + def gameIcon(self) -> QIcon: + return mobase.getIconForExecutable( + self.gameDirectory().absoluteFilePath(self.binaryName()) + ) + + def validShortNames(self) -> list[str]: + return self._mappings.validShortNames.get() + + def gameNexusName(self) -> str: + return self._mappings.gameNexusName.get() + + def gameThunderstoreName(self) -> str: + return self._mappings.gameThunderstoreName.get() + + def nexusModOrganizerID(self) -> int: + return 0 + + def nexusGameID(self) -> int: + return self._mappings.nexusGameId.get() + + def steamAPPId(self) -> str: + return self._mappings.steamAPPId.current() + + def gogAPPId(self) -> str: + return self._mappings.gogAPPId.current() + + def epicAPPId(self) -> str: + return self._mappings.epicAPPId.current() + + def eaDesktopContentId(self) -> str: + return self._mappings.eaDesktopContentId.current() + + def binaryName(self) -> str: + return self._mappings.binaryName.get() + + def getLauncherName(self) -> str: + return self._mappings.launcherName.get() + + def getSupportURL(self) -> str: + return self._mappings.supportURL.get() + + def iniFiles(self) -> list[str]: + return self._mappings.iniFiles.get() + + def executables(self) -> list[mobase.ExecutableInfo]: + execs: list[mobase.ExecutableInfo] = [] + if self.getLauncherName(): + execs.append( + mobase.ExecutableInfo( + self.gameName(), + QFileInfo( + self.gameDirectory().absoluteFilePath(self.getLauncherName()) + ), + ) + ) + execs.append( + mobase.ExecutableInfo( + self.gameName(), + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ) + return execs + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + return [] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + BasicGameSaveGame(path) + for path in Path(folder.absolutePath()).glob(f"**/*.{ext}") + ] + + def initializeProfile( + self, directory: QDir, settings: mobase.ProfileSetting + ) -> None: + if settings & mobase.ProfileSetting.CONFIGURATION: + for iniFile in self.iniFiles(): + try: + shutil.copyfile( + self.documentsDirectory().absoluteFilePath(iniFile), + directory.absoluteFilePath(QFileInfo(iniFile).fileName()), + ) + except FileNotFoundError: + Path( + directory.absoluteFilePath(QFileInfo(iniFile).fileName()) + ).touch() + + def setGameVariant(self, variant: str) -> None: + pass + + def gameVersion(self) -> str: + return mobase.getFileVersion( + self.gameDirectory().absoluteFilePath(self.binaryName()) + ) + + def looksValid(self, directory: QDir): + return directory.exists(self.binaryName()) + + def isInstalled(self) -> bool: + return bool(self._gamePath) + + def gameDirectory(self) -> QDir: + """ + @return directory (QDir) to the game installation. + """ + return QDir(self._gamePath) + + def dataDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absoluteFilePath(self._mappings.dataDirectory.get()) + ) + + def setGamePath(self, path: Path | str) -> None: + self._gamePath = str(path) + + path = Path(path) + + # Check if we have a matching steam, GOG, Origin or EA Desktop id and set the + # index accordingly: + for steamid, steampath in BasicGame.steam_games.items(): + if steampath == path: + self._mappings.steamAPPId.set_value(steamid) + for gogid, gogpath in BasicGame.gog_games.items(): + if gogpath == path: + self._mappings.gogAPPId.set_value(gogid) + for originid, originpath in BasicGame.origin_games.items(): + if originpath == path: + self._mappings.originManifestIds.set_value(originid) + for epicid, epicpath in BasicGame.epic_games.items(): + if epicpath == path: + self._mappings.epicAPPId.set_value(epicid) + for eadesktopid, eadesktoppath in BasicGame.eadesktop_games.items(): + if eadesktoppath == path: + self._mappings.eaDesktopContentId.set_value(eadesktopid) + + def documentsDirectory(self) -> QDir: + return self._mappings.documentsDirectory.get() + + def savesDirectory(self) -> QDir: + return self._mappings.savesDirectory.get() diff --git a/libs/basic_games/basic_game_ini.py b/libs/basic_games/basic_game_ini.py new file mode 100644 index 0000000..c9e0af6 --- /dev/null +++ b/libs/basic_games/basic_game_ini.py @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- + +import configparser +import os + +from .basic_game import BasicGame + + +class BasicIniGame(BasicGame): + def __init__(self, path: str): + # Set the _fromName to get more "correct" errors: + self._fromName = os.path.basename(path) + + # Read the file: + config = configparser.ConfigParser() + config.optionxform = str # type: ignore + config.read(path) + + # Just fill the class with values: + for k, v in config["DEFAULT"].items(): + setattr(self, k, v) + + super().__init__() diff --git a/libs/basic_games/eadesktop_utils.py b/libs/basic_games/eadesktop_utils.py new file mode 100644 index 0000000..5720556 --- /dev/null +++ b/libs/basic_games/eadesktop_utils.py @@ -0,0 +1,85 @@ +# -*- encoding: utf-8 -*- + +import configparser +import os +import sys +import xml.etree.ElementTree as et +from configparser import NoOptionError +from pathlib import Path +from typing import Dict + + +def find_games(errors: list[tuple[str, Exception]] | None = None) -> Dict[str, Path]: + """ + Find the list of EA Desktop games installed. + + Returns: + A mapping from EA Desktop content IDs to install locations for available + EA Desktop games. + """ + games: Dict[str, Path] = {} + + local_app_data_path = os.path.expandvars("%LocalAppData%") + ea_desktop_settings_path = Path(local_app_data_path).joinpath( + "Electronic Arts", "EA Desktop" + ) + + if not ea_desktop_settings_path.exists(): + return games + + try: + user_ini, *_ = list(ea_desktop_settings_path.glob("user_*.ini")) + except ValueError: + return games + + # The INI file in its current form has no section headers. + # So we wrangle the input to add it all under a fake section. + with open(user_ini) as f: + ini_content = "[mod_organizer]\n" + f.read() + + config = configparser.ConfigParser() + try: + config.read_string(ini_content) + except configparser.ParsingError as e: + error_message = ( + f'Failed to parse EA Desktop games list file "{user_ini}",\n' + " Try to run the launcher to recreate it." + ) + print(error_message, e, file=sys.stderr) + if errors is not None: + errors.append((error_message, e)) + return games + + try: + install_path = Path(config.get("mod_organizer", "user.downloadinplacedir")) + except NoOptionError: + install_path = Path(os.environ["ProgramW6432"]) / "EA Games" + config.set("mod_organizer", "user.downloadinplacedir", install_path.__str__()) + + if not install_path.exists(): + return games + + for game_dir in install_path.iterdir(): + try: + installer_file = game_dir.joinpath("__Installer", "installerdata.xml") + xml_tree = et.parse(installer_file) + root = xml_tree.getroot() + + # For all manifest files the following XPath expression returns the + # numeric ID. There are, in some cases, also name IDs but we do not + # consider these. + content_id = root.find(".//contentIDs/contentID[1]") + + if content_id is not None and content_id.text: + game_id = content_id.text + games[game_id] = game_dir + except FileNotFoundError: + pass + + return games + + +if __name__ == "__main__": + games = find_games() + for k, v in games.items(): + print("Found game with id {} at {}.".format(k, v)) diff --git a/libs/basic_games/epic_utils.py b/libs/basic_games/epic_utils.py new file mode 100644 index 0000000..94ae6a9 --- /dev/null +++ b/libs/basic_games/epic_utils.py @@ -0,0 +1,108 @@ +# -*- encoding: utf-8 -*- +from __future__ import annotations + +import itertools +import json +import os +import sys +from collections.abc import Iterable +from pathlib import Path + +try: + import winreg +except ImportError: + winreg = None + +ErrorList = list[tuple[str, Exception]] + + +def find_epic_games( + errors: ErrorList | None = None, +) -> Iterable[tuple[str, Path]]: + if winreg is None: + return + + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"Software\Wow6432Node\Epic Games\EpicGamesLauncher", + ) as key: + epic_data_path, _ = winreg.QueryValueEx(key, "AppDataPath") + except FileNotFoundError: + epic_data_path = r"%ProgramData%\Epic\EpicGamesLauncher\Data" + + manifests_path = Path(os.path.expandvars(epic_data_path)).joinpath("Manifests") + if manifests_path.exists(): + for manifest_file_path in manifests_path.glob("*.item"): + try: + with open(manifest_file_path, encoding="utf-8") as manifest_file: + manifest_file_data = json.load(manifest_file) + yield ( + manifest_file_data["AppName"], + Path(manifest_file_data["InstallLocation"]), + ) + except (json.JSONDecodeError, KeyError) as e: + error_message = ( + f'Unable to parse Epic Games manifest file: "{manifest_file_path}"\n' + " Try to run the launcher recreate it." + ) + print( + error_message, + e, + file=sys.stderr, + ) + if errors is not None: + errors.append((error_message, e)) + + +def find_legendary_games( + config_path: str | None = None, errors: ErrorList | None = None +) -> Iterable[tuple[str, Path]]: + # Based on legendary source: + # https://github.com/derrod/legendary/blob/master/legendary/lfs/lgndry.py + if config_path := config_path or os.environ.get("XDG_CONFIG_HOME"): + legendary_config_path = Path(config_path, "legendary") + else: + legendary_config_path = Path("~/.config/legendary").expanduser() + + installed_path = legendary_config_path / "installed.json" + if installed_path.exists(): + try: + with open(installed_path, encoding="utf-8") as installed_file: + installed_games = json.load(installed_file) + for game in installed_games.values(): + yield game["app_name"], Path(game["install_path"]) + except (json.JSONDecodeError, AttributeError, KeyError) as e: + error_message = ( + f'Unable to parse installed games from Legendary/Heroic launcher: "{installed_path}"\n' + " Try to run the launcher to recrated the file." + ) + print( + error_message, + e, + file=sys.stderr, + ) + if errors is not None: + errors.append((error_message, e)) + + +def find_heroic_games(errors: ErrorList | None = None): + return find_legendary_games( + os.path.expandvars(r"%AppData%\heroic\legendaryConfig"), errors + ) + + +def find_games(errors: ErrorList | None = None) -> dict[str, Path]: + return dict( + itertools.chain( + find_epic_games(errors=errors), + find_legendary_games(errors=errors), + find_heroic_games(errors=errors), + ) + ) + + +if __name__ == "__main__": + games = find_games() + for k, v in games.items(): + print("Found game with id {} at {}.".format(k, v)) diff --git a/libs/basic_games/games/__init__.py b/libs/basic_games/games/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libs/basic_games/games/__init__.py diff --git a/libs/basic_games/games/baldursgate3/__init__.py b/libs/basic_games/games/baldursgate3/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/__init__.py diff --git a/libs/basic_games/games/baldursgate3/bg3_data_checker.py b/libs/basic_games/games/baldursgate3/bg3_data_checker.py new file mode 100644 index 0000000..695d21c --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_data_checker.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import mobase + +from ...basic_features import BasicModDataChecker, GlobPatterns, utils +from . import bg3_utils + + +class BG3ModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + valid=[ + "*.pak", + str(Path("Mods") / "*.pak"), # standard mods + "bin", # native mods / Script Extender + "Script Extender", # mods which are configured via jsons in this folder + "Data", # loose file mods + ] + + [str(Path("*") / f) for f in bg3_utils.loose_file_folders], + move={ + "Root/": "", # root builder not needed + "*.dll": "bin/", + "ScriptExtenderSettings.json": "bin/", + } + | {f: "Data/" for f in bg3_utils.loose_file_folders}, + delete=["info.json", "*.txt"], + ) + ) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + if rp.unfold.match(name): + if utils.is_directory(entry): + status = self.dataLooksValid(entry) + else: + status = mobase.ModDataChecker.INVALID + break + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif isinstance(entry, mobase.IFileTree): + status = ( + mobase.ModDataChecker.VALID + if all(rp.valid.match(e.pathFrom(filetree)) for e in entry) + else mobase.ModDataChecker.INVALID + ) + elif rp.delete.match(name) or rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + else: + status = mobase.ModDataChecker.INVALID + break + return status diff --git a/libs/basic_games/games/baldursgate3/bg3_data_content.py b/libs/basic_games/games/baldursgate3/bg3_data_content.py new file mode 100644 index 0000000..c378520 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_data_content.py @@ -0,0 +1,54 @@ +from enum import IntEnum, auto + +import mobase + +from . import bg3_utils + + +class Content(IntEnum): + PAK = auto() + WORKSPACE = auto() + NATIVE = auto() + LOOSE_FILES = auto() + SE_FILES = auto() + + +class BG3DataContent(mobase.ModDataContent): + BG3_CONTENTS: list[tuple[Content, str, str, bool] | tuple[Content, str, str]] = [ + (Content.WORKSPACE, "Mod workspace", ":/MO/gui/content/script"), + (Content.PAK, "Pak", ":/MO/gui/content/bsa"), + (Content.LOOSE_FILES, "Loose file override mod", ":/MO/gui/content/texture"), + (Content.SE_FILES, "Script Extender Files", ":/MO/gui/content/inifile"), + (Content.NATIVE, "Native DLL mod", ":/MO/gui/content/plugin"), + ] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content(id, name, icon, *filter_only) + for id, name, icon, *filter_only in self.BG3_CONTENTS + ] + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + contents: set[int] = set() + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + match entry.name(): + case "Script Extender": + contents.add(Content.SE_FILES) + case "Data": + contents.add(Content.LOOSE_FILES) + case "Mods": + for e in entry: + if e.name().endswith(".pak"): + contents.add(Content.PAK) + break + case "bin": + contents.add(Content.NATIVE) + case _: + for e in entry: + if e.name() in bg3_utils.loose_file_folders: + contents.add(Content.WORKSPACE) + break + elif entry.name().endswith(".pak"): + contents.add(Content.PAK) + return list(contents) diff --git a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py new file mode 100644 index 0000000..0ae6ef8 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py @@ -0,0 +1,131 @@ +import functools +import os +from pathlib import Path +from typing import Callable, Optional + +from PyQt6.QtCore import QDir, QLoggingCategory, qDebug, qInfo, qWarning +from PyQt6.QtWidgets import QApplication + +import mobase + +from . import bg3_utils + + +class BG3FileMapper(mobase.IPluginFileMapper): + current_mappings: list[mobase.Mapping] = [] + + def __init__(self, utils: bg3_utils.BG3Utils, doc_dir: Callable[[], QDir]): + super().__init__() + self._utils = utils + self.doc_dir = doc_dir + + @functools.cached_property + def doc_path(self): + return Path(self.doc_dir().path()) + + def mappings(self) -> list[mobase.Mapping]: + qInfo("creating custom bg3 mappings") + self.current_mappings.clear() + active_mods = self._utils.active_mods() + if not active_mods: + return [] + progress = self._utils.create_progress_window( + "Mapping files to documents folder", len(active_mods) + 1 + ) + docs_path_mods = self.doc_path / "Mods" + docs_path_se = self.doc_path / "Script Extender" + for mod in active_mods: + modpath = Path(mod.absolutePath()) + self.map_files(modpath, dest=docs_path_mods, pattern="*.pak", rel=False) + self.map_files(modpath / "Script Extender", dest=docs_path_se) + if self._utils.convert_yamls_to_json: + self.map_files(modpath / "bin", only_convert=True) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("mapping canceled by user") + return self.current_mappings + (self._utils.overwrite_path / "Script Extender").mkdir( + parents=True, exist_ok=True + ) + (self._utils.overwrite_path / "Stats").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "Temp").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "LevelCache").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "Stats").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "Mods").mkdir(parents=True, exist_ok=True) + (self._utils.overwrite_path / "GMCampaigns").mkdir(parents=True, exist_ok=True) + self.map_files(self._utils.overwrite_path) + self.create_mapping( + self._utils.modsettings_path, + self.doc_path + / "PlayerProfiles" + / "Public" + / self._utils.modsettings_path.name, + ) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + cat = QLoggingCategory.defaultCategory() + if cat is not None and cat.isDebugEnabled(): + qDebug( + f"resolved mappings: { {m.source: m.destination for m in self.current_mappings} }" + ) + return self.current_mappings + + def map_files( + self, + path: Path, + dest: Optional[Path] = None, + pattern: str = "*", + rel: bool = True, + only_convert: bool = False, + ): + dest = dest if dest else self.doc_path + dest_func: Callable[[Path], str] = ( + (lambda f: os.path.relpath(f, path)) if rel else lambda f: f.name + ) + found_jsons: set[Path] = set() + for file in list(path.rglob(pattern)): + if self._utils.convert_yamls_to_json and ( + file.name.endswith(".yaml") or file.name.endswith(".yml") + ): + converted_path = file.parent / file.name.replace( + ".yaml", ".json" + ).replace(".yml", ".json") + try: + if not converted_path.exists() or os.path.getmtime( + file + ) > os.path.getmtime(converted_path): + import json + + import yaml + + with open(file, "r") as yaml_file: + with open(converted_path, "w") as json_file: + json.dump( + yaml.safe_load(yaml_file), json_file, indent=2 + ) + qDebug(f"Converted {file} to JSON") + found_jsons.add(converted_path) + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") + elif file.name.endswith(".json"): + found_jsons.add(file) + elif not only_convert: + self.create_mapping(file, dest / dest_func(file)) + if only_convert: + return + for file in found_jsons: + self.create_mapping(file, dest / dest_func(file)) + + def create_mapping(self, file: Path, dest: Path): + bg3_utils.create_dir_if_needed(dest) + + self.current_mappings.append( + mobase.Mapping( + source=str(file), + destination=str(dest), + is_directory=file.is_dir(), + create_target=True, + ) + ) diff --git a/libs/basic_games/games/baldursgate3/bg3_utils.py b/libs/basic_games/games/baldursgate3/bg3_utils.py new file mode 100644 index 0000000..958d173 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/bg3_utils.py @@ -0,0 +1,274 @@ +import functools +import shutil +import typing +from pathlib import Path +from time import sleep + +from PyQt6.QtCore import ( + QCoreApplication, + QDir, + QEventLoop, + QRunnable, + Qt, + QThread, + QThreadPool, + qInfo, + qWarning, +) +from PyQt6.QtWidgets import QApplication, QMainWindow, QProgressDialog + +import mobase + +loose_file_folders = { + "Public", + "Mods", + "Generated", + "Localization", + "ScriptExtender", +} + + +def get_node_string( + folder: str = "", + md5: str = "", + name: str = "", + publish_handle: str = "0", + uuid: str = "", + version64: str = "0", +) -> str: + return f""" + <node id="ModuleShortDesc"> + <attribute id="Folder" type="LSString" value="{folder}"/> + <attribute id="MD5" type="LSString" value="{md5}"/> + <attribute id="Name" type="LSString" value="{name}"/> + <attribute id="PublishHandle" type="uint64" value="{publish_handle}"/> + <attribute id="UUID" type="guid" value="{uuid}"/> + <attribute id="Version64" type="int64" value="{version64}"/> + </node>""" + + +class BG3Utils: + _mod_settings_xml_start = """\ +<?xml version="1.0" encoding="UTF-8"?> +<save> + <version major="4" minor="8" revision="0" build="500"/> + <region id="ModuleSettings"> + <node id="root"> + <children> + <node id="Mods"> + <children>""" + get_node_string( + folder="GustavX", + name="GustavX", + uuid="cb555efe-2d9e-131f-8195-a89329d218ea", + version64="36028797018963968", + ) + _mod_settings_xml_end = """ + </children> + </node> + </children> + </node> + </region> +</save>""" + + def __init__(self, name: str): + self.main_window = None + self._name = name + from . import lslib_retriever, pak_parser + + self.lslib_retriever = lslib_retriever.LSLibRetriever(self) + self._pak_parser = pak_parser.BG3PakParser(self) + + def init(self, organizer: mobase.IOrganizer): + self._organizer = organizer + + @functools.cached_property + def autobuild_paks(self): + return bool(self.get_setting("autobuild_paks")) + + @functools.cached_property + def extract_full_package(self): + return bool(self.get_setting("extract_full_package")) + + @functools.cached_property + def remove_extracted_metadata(self): + return bool(self.get_setting("remove_extracted_metadata")) + + @functools.cached_property + def force_load_dlls(self): + return bool(self.get_setting("force_load_dlls")) + + @functools.cached_property + def log_diff(self): + return bool(self.get_setting("log_diff")) + + @functools.cached_property + def convert_yamls_to_json(self): + return bool(self.get_setting("convert_yamls_to_json")) + + @functools.cached_property + def log_dir(self): + return create_dir_if_needed(Path(self._organizer.basePath()) / "logs") + + @functools.cached_property + def modsettings_backup(self): + return create_dir_if_needed(self.plugin_data_path / "temp" / "modsettings.lsx") + + @functools.cached_property + def modsettings_path(self): + return create_dir_if_needed( + Path(self._organizer.profilePath()) / "modsettings.lsx" + ) + + @functools.cached_property + def plugin_data_path(self) -> Path: + """Gets the path to the data folder for the current plugin.""" + return create_dir_if_needed( + Path(self._organizer.pluginDataPath(), self._name).absolute() + ) + + @functools.cached_property + def tools_dir(self): + return create_dir_if_needed(self.plugin_data_path / "tools") + + @functools.cached_property + def overwrite_path(self): + return create_dir_if_needed(Path(self._organizer.overwritePath())) + + def active_mods(self) -> list[mobase.IModInterface]: + modlist = self._organizer.modList() + return [ + modlist.getMod(mod_name) + for mod_name in filter( + lambda mod: modlist.state(mod) & mobase.ModState.ACTIVE, + modlist.allModsByProfilePriority(), + ) + ] + + def _set_setting(self, key: str, value: mobase.MoVariant): + self._organizer.setPluginSetting(self._name, key, value) + + def get_setting(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self._name, key) + + def tr(self, trstr: str) -> str: + return QCoreApplication.translate(self._name, trstr) + + def create_progress_window( + self, title: str, max_progress: int, msg: str = "", cancelable: bool = True + ) -> QProgressDialog: + progress = QProgressDialog( + self.tr(msg if msg else title), + self.tr("Cancel") if cancelable else None, + 0, + max_progress, + self.main_window, + ) + progress.setWindowTitle(self.tr(f"BG3 Plugin: {title}")) + progress.setWindowModality(Qt.WindowModality.ApplicationModal) + progress.show() + return progress + + def on_user_interface_initialized(self, window: QMainWindow) -> None: + self.main_window = window + + def on_settings_changed( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ) -> None: + if self._name != plugin_name: + return + if setting in { + "extract_full_package", + "autobuild_paks", + "remove_extracted_metadata", + "force_load_dlls", + "log_diff", + "convert_yamls_to_json", + } and hasattr(self, setting): + delattr(self, setting) + + def construct_modsettings_xml( + self, + exec_path: str = "", + working_dir: typing.Optional[QDir] = None, + args: str = "", + force_reparse_metadata: bool = False, + ) -> bool: + if ( + "bin/bg3" not in exec_path + or not self.lslib_retriever.download_lslib_if_missing() + ): + return True + active_mods = self.active_mods() + progress = self.create_progress_window( + "Generating modsettings.xml", len(active_mods) + ) + threadpool = QThreadPool.globalInstance() + if threadpool is None: + return False + metadata: dict[str, str] = {} + + def retrieve_mod_metadata_in_new_thread(mod: mobase.IModInterface): + return lambda: metadata.update( + self._pak_parser.get_metadata_for_files_in_mod( + mod, force_reparse_metadata + ) + ) + + for mod in active_mods: + if progress.wasCanceled(): + qWarning("processing canceled by user") + return False + threadpool.start(QRunnable.create(retrieve_mod_metadata_in_new_thread(mod))) + count = 0 + num_active_mods = len(active_mods) + total_intervals_to_wait = (num_active_mods * 2) + 20 + while len(metadata.keys()) < num_active_mods: + progress.setValue(len(metadata.keys())) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + count += 1 + if count == total_intervals_to_wait or progress.wasCanceled(): + remaining_mods = {mod.name() for mod in active_mods} - metadata.keys() + qWarning(f"processing did not finish in time for: {remaining_mods}") + progress.close() + break + QThread.msleep(100) + progress.setValue(num_active_mods) + QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 100) + progress.close() + qInfo(f"writing mod load order to {self.modsettings_path}") + self.modsettings_path.parent.mkdir(parents=True, exist_ok=True) + self.modsettings_path.write_text( + ( + self._mod_settings_xml_start + + "".join( + metadata[mod.name()] + for mod in active_mods + if mod.name() in metadata + ) + + self._mod_settings_xml_end + ) + ) + qInfo( + f"backing up generated file {self.modsettings_path} to {self.modsettings_backup}, " + f"check the backup after the executable runs for differences with the file used by the game if you encounter issues" + ) + self.modsettings_backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(self.modsettings_path, self.modsettings_backup) + sleep(0.5) + return True + + def on_mod_installed(self, mod: mobase.IModInterface) -> None: + if self.lslib_retriever.download_lslib_if_missing(): + self._pak_parser.get_metadata_for_files_in_mod(mod, True) + + +def create_dir_if_needed(path: Path) -> Path: + if "." not in path.name[1:]: + path.mkdir(parents=True, exist_ok=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + return path diff --git a/libs/basic_games/games/baldursgate3/lslib_retriever.py b/libs/basic_games/games/baldursgate3/lslib_retriever.py new file mode 100644 index 0000000..ee1122c --- /dev/null +++ b/libs/basic_games/games/baldursgate3/lslib_retriever.py @@ -0,0 +1,162 @@ +import json +import shutil +import traceback +import urllib.request +import zipfile +from functools import cached_property + +from PyQt6.QtCore import qDebug, qWarning +from PyQt6.QtWidgets import QApplication, QMessageBox + +from . import bg3_utils + + +class LSLibRetriever: + def __init__(self, utils: bg3_utils.BG3Utils): + self._utils = utils + + @cached_property + def _needed_lslib_files(self): + return { + self._utils.tools_dir / x + for x in { + "CommandLineArgumentsParser.dll", + "Divine.dll", + "Divine.dll.config", + "Divine.exe", + "Divine.runtimeconfig.json", + "K4os.Compression.LZ4.dll", + "K4os.Compression.LZ4.Streams.dll", + "LSLib.dll", + "LSLibNative.dll", + "LZ4.dll", + "Newtonsoft.Json.dll", + "System.IO.Hashing.dll", + "ZstdSharp.dll", + } + } + + def download_lslib_if_missing(self, force: bool = False) -> bool: + if not force and all(x.exists() for x in self._needed_lslib_files): + return True + try: + self._utils.tools_dir.mkdir(exist_ok=True, parents=True) + downloaded = False + + def reporthook(block_num: int, block_size: int, total_size: int) -> None: + if total_size > 0: + progress.setValue( + min(int(block_num * block_size * 100 / total_size), 100) + ) + QApplication.processEvents() + + with urllib.request.urlopen( + "https://api.github.com/repos/Norbyte/lslib/releases/latest" + ) as response: + assets = json.loads(response.read().decode("utf-8"))["assets"][0] + zip_path = self._utils.tools_dir / assets["name"] + if not zip_path.exists(): + old_archives = list(self._utils.tools_dir.glob("*.zip")) + msg_box = QMessageBox(self._utils.main_window) + msg_box.setWindowTitle( + self._utils.tr("Baldur's Gate 3 Plugin - Missing dependencies") + ) + if old_archives: + msg_box.setText(self._utils.tr("LSLib update available.")) + else: + msg_box.setText( + self._utils.tr( + "LSLib tools are missing.\nThese are necessary for the plugin to create the load order file for BG3." + ) + ) + msg_box.addButton( + self._utils.tr("Download"), + QMessageBox.ButtonRole.DestructiveRole, + ) + exit_btn = msg_box.addButton( + self._utils.tr("Exit"), QMessageBox.ButtonRole.ActionRole + ) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.exec() + + if msg_box.clickedButton() == exit_btn: + if not old_archives: + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + "LSLib tools are required for the proper generation of the modsettings.xml file, file will not be generated" + ) + err.exec() + return False + else: + progress = self._utils.create_progress_window( + "Downloading LSLib", 100, cancelable=False + ) + urllib.request.urlretrieve( + assets["browser_download_url"], str(zip_path), reporthook + ) + progress.close() + downloaded = True + for archive in old_archives: + archive.unlink() + old_archives = [] + else: + old_archives = [] + new_msg = QMessageBox(self._utils.main_window) + new_msg.setIcon(QMessageBox.Icon.Information) + new_msg.setText( + self._utils.tr("Latest version of LSLib already downloaded!") + ) + new_msg.exec() + + except Exception as e: + qDebug(f"Download failed: {e}") + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + self._utils.tr( + f"Failed to download LSLib tools:\n{traceback.format_exc()}" + ) + ) + err.exec() + return False + try: + if old_archives: + zip_path = sorted(old_archives)[-1] + if old_archives or not downloaded: + dialog_message = "Ensuring all necessary LSLib files have been extracted from archive..." + win_title = "Verifying LSLib files" + else: + dialog_message = "Extracting/Updating LSLib files..." + win_title = "Extracting LSLib" + x_progress = self._utils.create_progress_window( + win_title, len(self._needed_lslib_files), msg=dialog_message + ) + with zipfile.ZipFile(zip_path, "r") as zip_ref: + for file in self._needed_lslib_files: + if downloaded or not file.exists(): + shutil.move( + zip_ref.extract( + f"Packed/Tools/{file.name}", self._utils.tools_dir + ), + file, + ) + x_progress.setValue(x_progress.value() + 1) + QApplication.processEvents() + if x_progress.wasCanceled(): + qWarning("processing canceled by user") + return False + x_progress.close() + shutil.rmtree(self._utils.tools_dir / "Packed", ignore_errors=True) + except Exception as e: + qDebug(f"Extraction failed: {e}") + err = QMessageBox(self._utils.main_window) + err.setIcon(QMessageBox.Icon.Critical) + err.setText( + self._utils.tr( + f"Failed to extract LSLib tools:\n{traceback.format_exc()}" + ) + ) + err.exec() + return False + return True diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py new file mode 100644 index 0000000..18598a9 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -0,0 +1,295 @@ +import configparser +import hashlib +import os +import re +import shutil +import subprocess +import traceback +from functools import cached_property +from pathlib import Path +from typing import Callable +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from PyQt6.QtCore import ( + qDebug, + qInfo, + qWarning, +) + +import mobase + +from . import bg3_utils + + +class BG3PakParser: + def __init__(self, utils: bg3_utils.BG3Utils): + self._utils = utils + + _mod_cache: dict[Path, bool] = {} + _types = { + "Folder": "", + "MD5": "", + "Name": "", + "PublishHandle": "0", + "UUID": "", + "Version64": "0", + } + + @cached_property + def _divine_command(self): + return f"{self._utils.tools_dir / 'Divine.exe'} -g bg3 -l info" + + @cached_property + def _folder_pattern(self): + return re.compile("Data|Script Extender|bin|Mods") + + def get_metadata_for_files_in_mod( + self, mod: mobase.IModInterface, force_reparse_metadata: bool + ): + return { + mod.name(): "".join( + [ + self._get_metadata_for_file(mod, file, force_reparse_metadata) + for file in sorted( + list(Path(mod.absolutePath()).rglob("*.pak")) + + ( + [ + f + for f in Path(mod.absolutePath()).glob("*") + if f.is_dir() + ] + if self._utils.autobuild_paks + else [] + ) + ) + ] + ) + } + + def _get_metadata_for_file( + self, + mod: mobase.IModInterface, + file: Path, + force_reparse_metadata: bool, + ) -> str: + meta_ini = Path(mod.absolutePath()) / "meta.ini" + config = configparser.ConfigParser(interpolation=None) + config.read(meta_ini, encoding="utf-8") + try: + if file.name.endswith("pak"): + meta_file = ( + self._utils.plugin_data_path + / "temp" + / "extracted_metadata" + / f"{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" + ) + try: + if ( + not force_reparse_metadata + and config.has_section(file.name) + and ( + "override" in config[file.name].keys() + or "Folder" in config[file.name].keys() + ) + ): + return get_module_short_desc(config, file) + meta_file.parent.mkdir(parents=True, exist_ok=True) + meta_file.unlink(missing_ok=True) + out_dir = ( + str(meta_file)[:-4] if self._utils.extract_full_package else "" + ) + can_continue = True + if self.run_divine( + f'{"extract-package" if self._utils.extract_full_package else "extract-single-file -f meta.lsx"} -d "{meta_file if not self._utils.extract_full_package else out_dir}"', + file, + ).returncode: + can_continue = False + if can_continue and self._utils.extract_full_package: + qDebug(f"archive {file} extracted to {out_dir}") + if self.run_divine( + f'convert-resources -d "{out_dir}" -i lsf -o lsx -x "*.lsf"', + out_dir, + ).returncode: + qDebug( + f"failed to convert lsf files in {out_dir} to readable lsx" + ) + extracted_meta_files = list(Path(out_dir).rglob("meta.lsx")) + if len(extracted_meta_files) == 0: + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + can_continue = False + else: + shutil.copyfile( + extracted_meta_files[0], + meta_file, + ) + elif can_continue and not meta_file.exists(): + qInfo( + f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" + ) + can_continue = False + return self.metadata_to_ini( + config, file, mod, meta_ini, can_continue, lambda: meta_file + ) + finally: + if self._utils.remove_extracted_metadata: + meta_file.unlink(missing_ok=True) + if self._utils.extract_full_package: + Path(str(meta_file)[:-4]).unlink(missing_ok=True) + elif file.is_dir(): + if self._folder_pattern.search(file.name): + return "" + for folder in bg3_utils.loose_file_folders: + if next(file.glob(f"{folder}/*"), False): + break + else: + return "" + qInfo(f"packable dir: {file}") + if (file.parent / f"{file.name}.pak").exists() or ( + file.parent / "Mods" / f"{file.name}.pak" + ).exists(): + qInfo( + f"pak with same name as packable dir exists in mod directory. not packing dir {file}" + ) + return "" + parent_mod_name = file.parent.name.replace(" ", "_") + pak_path = ( + self._utils.overwrite_path + / f"Mods/{parent_mod_name}_{file.name}.pak" + ) + build_pak = True + if pak_path.exists(): + try: + pak_creation_time = os.path.getmtime(pak_path) + for root, _, files in file.walk(): + for f in files: + file_path = root.joinpath(f) + try: + if os.path.getmtime(file_path) > pak_creation_time: + break + except OSError as e: + qDebug(f"Error accessing file {file_path}: {e}") + break + else: + build_pak = False + except OSError as e: + qDebug(f"Error accessing file {pak_path}: {e}") + build_pak = False + if build_pak: + pak_path.unlink(missing_ok=True) + if self.run_divine( + f'create-package -d "{pak_path}"', file + ).returncode: + return "" + meta_files = list(file.glob("Mods/*/meta.lsx")) + return self.metadata_to_ini( + config, + file, + mod, + meta_ini, + len(meta_files) > 0, + lambda: meta_files[0], + ) + else: + return "" + except Exception: + qWarning(traceback.format_exc()) + return "" + + def run_divine( + self, action: str, source: Path | str + ) -> subprocess.CompletedProcess[str]: + command = f'{self._divine_command} -a {action} -s "{source}"' + result = subprocess.run( + command, + creationflags=subprocess.CREATE_NO_WINDOW, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode: + qWarning( + f"{command.replace(str(Path.home()), '~', 1).replace(str(Path.home()), '$HOME')}" + f" returned stdout: {result.stdout}, stderr: {result.stderr}, code {result.returncode}" + ) + return result + + def get_attr_value(self, root: Element, attr_id: str) -> str: + default_val = self._types.get(attr_id) or "" + attr = root.find(f".//attribute[@id='{attr_id}']") + return default_val if attr is None else attr.get("value", default_val) + + def metadata_to_ini( + self, + config: configparser.ConfigParser, + file: Path, + mod: mobase.IModInterface, + meta_ini: Path, + condition: bool, + to_parse: Callable[[], Path], + ): + config[file.name] = {} + if condition: + root = ( + ElementTree.parse(to_parse()) + .getroot() + .find(".//node[@id='ModuleInfo']") + ) + if root is None: + qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") + else: + section = config[file.name] + folder_name = self.get_attr_value(root, "Folder") + if file.is_dir(): + self._mod_cache[file] = ( + len(list(file.glob(f"*/{folder_name}/**"))) > 1 + or len( + list(file.glob("Public/Engine/Timeline/MaterialGroups/*")) + ) + > 0 + ) + elif file not in self._mod_cache: + # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: + # 1. it has files in Public/Engine/Timeline/MaterialGroups, or + # 2. it has files in Mods/<folder_name>/ other than the meta.lsx file, or + # 3. it has files in Public/<folder_name> + result = self.run_divine( + f'list-package --use-regex -x "(/{re.escape(folder_name)}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', + file, + ) + self._mod_cache[file] = ( + result.returncode == 0 and result.stdout.strip() != "" + ) + if self._mod_cache[file]: + for key in self._types: + section[key] = self.get_attr_value(root, key) + else: + qInfo(f"pak {file.name} determined to be an override mod") + section["override"] = "True" + section["Folder"] = folder_name + else: + config[file.name]["override"] = "True" + with open(meta_ini, "w+", encoding="utf-8") as f: + config.write(f) + return get_module_short_desc(config, file) + + +def get_module_short_desc(config: configparser.ConfigParser, file: Path) -> str: + if not config.has_section(file.name): + return "" + section: configparser.SectionProxy = config[file.name] + return ( + "" + if "override" in section.keys() or "Name" not in section.keys() + else bg3_utils.get_node_string( + folder=section["Folder"], + md5=section["MD5"], + name=section["Name"], + publish_handle=section["PublishHandle"], + uuid=section["UUID"], + version64=section["Version64"], + ) + ) diff --git a/libs/basic_games/games/baldursgate3/plugins/__init__.py b/libs/basic_games/games/baldursgate3/plugins/__init__.py new file mode 100644 index 0000000..1b69faf --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/__init__.py @@ -0,0 +1,13 @@ +import mobase + +from .check_for_lslib_updates_plugin import BG3ToolCheckForLsLibUpdates +from .convert_jsons_to_yaml_plugin import BG3ToolConvertJsonsToYaml +from .reparse_pak_metadata_plugin import BG3ToolReparsePakMetadata + + +def createPlugins() -> list[mobase.IPluginTool]: + return [ + BG3ToolCheckForLsLibUpdates(), + BG3ToolReparsePakMetadata(), + BG3ToolConvertJsonsToYaml(), + ] diff --git a/libs/basic_games/games/baldursgate3/plugins/bg3_tool_plugin.py b/libs/basic_games/games/baldursgate3/plugins/bg3_tool_plugin.py new file mode 100644 index 0000000..fc258a6 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/bg3_tool_plugin.py @@ -0,0 +1,49 @@ +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtGui import QIcon, QPixmap + +import mobase + + +class BG3ToolPlugin(mobase.IPluginTool, mobase.IPlugin): + desc = sub_name = "" + icon_bytes: bytes + + def __init__(self): + mobase.IPluginTool.__init__(self) + mobase.IPlugin.__init__(self) + self._pluginName = self._displayName = "BG3 Tools" + self._pluginVersion = mobase.VersionInfo(1, 0, 0) + pixmap = QPixmap() + pixmap.loadFromData(self.icon_bytes, "SVG") + self.qicon = QIcon(pixmap) + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + return True + + def version(self): + return self._pluginVersion + + def author(self): + return "daescha" + + def name(self): + return f"{self._pluginName}: {self.sub_name}" + + def displayName(self): + return f"{self._displayName}/{self.sub_name}" + + def tooltip(self): + return self.description() + + def enabledByDefault(self): + return self._organizer.managedGame().name() == "Baldur's Gate 3 Plugin" + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + def icon(self) -> QIcon: + return self.qicon + + def description(self) -> str: + return QCoreApplication.translate(self._pluginName, self.desc) diff --git a/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py b/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py new file mode 100644 index 0000000..b534e8a --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py @@ -0,0 +1,15 @@ +from .bg3_tool_plugin import BG3ToolPlugin +from .icons import download + + +class BG3ToolCheckForLsLibUpdates(BG3ToolPlugin): + icon_bytes = download + sub_name = "Check For LsLib Updates" + desc = "Check to see if there has been a new release of LSLib and create download dialog if so." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if isinstance(game_plugin, BG3Game): + game_plugin.utils.lslib_retriever.download_lslib_if_missing(True) diff --git a/libs/basic_games/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py b/libs/basic_games/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py new file mode 100644 index 0000000..fde3431 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/convert_jsons_to_yaml_plugin.py @@ -0,0 +1,58 @@ +import json +import os +from pathlib import Path + +from PyQt6.QtCore import qInfo, qWarning +from PyQt6.QtWidgets import QApplication + +from .bg3_tool_plugin import BG3ToolPlugin +from .icons import exchange + + +class BG3ToolConvertJsonsToYaml(BG3ToolPlugin): + icon_bytes = exchange + sub_name = "Convert JSONS to YAML" + desc = "Convert all jsons in active mods to yaml immediately." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if not isinstance(game_plugin, BG3Game): + return + utils = game_plugin.utils + qInfo("converting all json files to yaml") + active_mods = utils.active_mods() + progress = utils.create_progress_window( + "Converting all json files to yaml", len(active_mods) + 1 + ) + for mod in active_mods: + _convert_jsons_in_dir_to_yaml(Path(mod.absolutePath())) + progress.setValue(progress.value() + 1) + QApplication.processEvents() + if progress.wasCanceled(): + qWarning("conversion canceled by user") + return + _convert_jsons_in_dir_to_yaml(utils.overwrite_path) + progress.setValue(len(active_mods) + 1) + QApplication.processEvents() + progress.close() + + +def _convert_jsons_in_dir_to_yaml(path: Path): + for file in list(path.rglob("*.json")): + converted_path = file.parent / file.name.replace(".json", ".yaml") + try: + if not converted_path.exists() or os.path.getmtime(file) > os.path.getmtime( + converted_path + ): + import yaml + + with open(file, "r") as json_file: + with open(converted_path, "w") as yaml_file: + yaml.dump( + json.load(json_file), yaml_file, indent=2, sort_keys=False + ) + qInfo(f"Converted {file} to YAML") + except OSError as e: + qWarning(f"Error accessing file {converted_path}: {e}") diff --git a/libs/basic_games/games/baldursgate3/plugins/icons.py b/libs/basic_games/games/baldursgate3/plugins/icons.py new file mode 100644 index 0000000..adc1023 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/icons.py @@ -0,0 +1,42 @@ +refresh = b""" +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<!-- source: https://www.svgrepo.com/svg/168563/refresh --> +<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 496.166 496.166" xml:space="preserve"> + <path style="fill:#32BEA6;" d="M0.005,248.087C0.005,111.063,111.073,0,248.079,0c137.014,0,248.082,111.062,248.082,248.087 + c0,137.002-111.068,248.079-248.082,248.079C111.073,496.166,0.005,385.089,0.005,248.087z"/> + <path style="fill:#F7F7F7;" d="M400.813,169.581c-2.502-4.865-14.695-16.012-35.262-5.891 + c-20.564,10.122-10.625,32.351-10.625,32.351c7.666,15.722,11.98,33.371,11.98,52.046c0,65.622-53.201,118.824-118.828,118.824 + c-65.619,0-118.82-53.202-118.82-118.824c0-61.422,46.6-111.946,106.357-118.173v30.793c0,0-0.084,1.836,1.828,2.999 + c1.906,1.163,3.818,0,3.818,0l98.576-58.083c0,0,2.211-1.162,2.211-3.436c0-1.873-2.211-3.205-2.211-3.205l-98.248-57.754 + c0,0-2.24-1.605-4.23-0.826c-1.988,0.773-1.744,3.481-1.744,3.481v32.993c-88.998,6.392-159.23,80.563-159.23,171.21 + c0,94.824,76.873,171.696,171.693,171.696c94.828,0,171.707-76.872,171.707-171.696 + C419.786,219.788,412.933,193.106,400.813,169.581z"/> +</svg> +""" + +exchange = b""" +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools --> +<!-- source: https://www.svgrepo.com/svg/449729/exchange-alt-s (edited color) --> +<svg width="800px" height="800px" viewBox="-1 0 20 20" id="meteor-icon-kit__solid-exchange-alt-s" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g id="SVGRepo_bgCarrier" stroke-width="0"/> + <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/> + <g id="SVGRepo_iconCarrier"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M5.62132 6.5L6.06066 6.93934C6.64645 7.52513 6.64645 8.4749 6.06066 9.0607C5.47487 9.6464 4.52513 9.6464 3.93934 9.0607L0.93934 6.06066C0.35355 5.47487 0.35355 4.52513 0.93934 3.93934L3.93934 0.93934C4.52513 0.35355 5.47487 0.35355 6.06066 0.93934C6.64645 1.52513 6.64645 2.47487 6.06066 3.06066L5.62132 3.5H16C16.8284 3.5 17.5 4.17157 17.5 5V8C17.5 8.8284 16.8284 9.5 16 9.5C15.1716 9.5 14.5 8.8284 14.5 8V6.5H5.62132zM12.3787 13.5L11.9393 13.0607C11.3536 12.4749 11.3536 11.5251 11.9393 10.9393C12.5251 10.3536 13.4749 10.3536 14.0607 10.9393L17.0607 13.9393C17.6464 14.5251 17.6464 15.4749 17.0607 16.0607L14.0607 19.0607C13.4749 19.6464 12.5251 19.6464 11.9393 19.0607C11.3536 18.4749 11.3536 17.5251 11.9393 16.9393L12.3787 16.5H2C1.17157 16.5 0.5 15.8284 0.5 15V12C0.5 11.1716 1.17157 10.5 2 10.5C2.82843 10.5 3.5 11.1716 3.5 12V13.5H12.3787z" fill="#5046d2"/> + </g> +</svg> +""" + +download = b""" +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<!-- source: https://www.svgrepo.com/svg/243947/download --> +<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" xml:space="preserve"> + <polygon style="fill:#CFF09E;" points="319.666,253.182 319.666,95.093 192.334,95.093 192.334,253.182 131.742,253.182 256,416.907 380.258,253.182 "/> + <g> + <path style="fill:#507C5C;" d="M256,431.967c-4.709,0-9.148-2.203-11.996-5.954L119.748,262.287 c-3.458-4.555-4.036-10.678-1.492-15.8c2.543-5.123,7.769-8.364,13.488-8.364h45.533V95.092c0-8.315,6.742-15.059,15.059-15.059 h127.331c8.317,0,15.059,6.743,15.059,15.059v143.03h45.533c5.719,0,10.945,3.239,13.488,8.364 c2.543,5.122,1.965,11.244-1.492,15.8L267.997,426.011C265.148,429.764,260.709,431.967,256,431.967z M162.075,268.241L256,391.999 l93.925-123.758h-30.259c-8.317,0-15.059-6.743-15.059-15.059V110.151h-97.214v143.03c0,8.315-6.742,15.059-15.059,15.059h-30.259 V268.241z"/> + <path style="fill:#507C5C;" d="M256,512C114.842,512,0,397.158,0,256S114.842,0,256,0c8.317,0,15.059,6.743,15.059,15.059 S264.317,30.118,256,30.118C131.448,30.118,30.118,131.448,30.118,256S131.448,481.882,256,481.882S481.882,380.552,481.882,256 c0-68.911-30.874-133.183-84.706-176.34c-6.489-5.203-7.532-14.681-2.33-21.17c5.203-6.49,14.679-7.529,21.168-2.331 C477.015,105.064,512,177.903,512,256C512,397.158,397.158,512,256,512z"/> + </g> +</svg> +""" diff --git a/libs/basic_games/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py b/libs/basic_games/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py new file mode 100644 index 0000000..f32a8a0 --- /dev/null +++ b/libs/basic_games/games/baldursgate3/plugins/reparse_pak_metadata_plugin.py @@ -0,0 +1,17 @@ +from .bg3_tool_plugin import BG3ToolPlugin +from .icons import refresh + + +class BG3ToolReparsePakMetadata(BG3ToolPlugin): + icon_bytes = refresh + sub_name = "Reparse Pak Metadata" + desc = "Force reparsing mod metadata immediately." + + def display(self): + from ...game_baldursgate3 import BG3Game + + game_plugin = self._organizer.managedGame() + if isinstance(game_plugin, BG3Game): + game_plugin.utils.construct_modsettings_xml( + exec_path="bin/bg3", force_reparse_metadata=True + ) diff --git a/libs/basic_games/games/game_arkhamcity.py b/libs/basic_games/games/game_arkhamcity.py new file mode 100644 index 0000000..0b8269f --- /dev/null +++ b/libs/basic_games/games/game_arkhamcity.py @@ -0,0 +1,87 @@ +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_game import BasicGame +from ..steam_utils import find_steam_path + + +# Lifted from https://github.com/ModOrganizer2/modorganizer-basic_games/blob/71dbb8c557d43cba9d290674a332e7ecd1650261/games/game_darkestdungeon.py +class ArkhamCityModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "config", + "cookedpcconsole", + "localization", + "movies", + "moviesstereo", + "splash", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class ArkhamCityGame(BasicGame): + Name = "Batman: Arkham City Plugin" + Author = "Paynamia" + Version = "0.5.3" + + GameName = "Batman: Arkham City" + GameShortName = "batmanarkhamcity" + GameNexusId = 372 + GameSteamId = 200260 + GameGogId = 1260066469 + GameEpicId = "Egret" + GameBinary = "Binaries/Win32/BatmanAC.exe" + GameLauncher = "Binaries/Win32/BmLauncher.exe" + GameDataPath = "BmGame" + GameDocumentsDirectory = ( + "%DOCUMENTS%/WB Games/Batman Arkham City GOTY/BmGame/Config" + ) + GameIniFiles = ["UserEngine.ini", "UserGame.ini", "UserInput.ini"] + GameSaveExtension = "sgd" + + # This will only detect saves from the earliest-created Steam profile on the user's PC. + def savesDirectory(self) -> QDir: + docSaves = QDir(self.documentsDirectory().cleanPath("../../SaveData")) + if self.is_steam(): + if (steamDir := find_steam_path()) is None: + return docSaves + for child in steamDir.joinpath("userdata").iterdir(): + if not child.is_dir() or child.name == "0": + continue + steamSaves = child.joinpath("200260", "remote") + if steamSaves.is_dir(): + return QDir(str(steamSaves)) + else: + return docSaves + else: + return docSaves + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(ArkhamCityModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "Batman: Arkham City", + QFileInfo(self.gameDirectory(), "Binaries/Win32/BatmanAC.exe"), + ), + mobase.ExecutableInfo( + "Arkham City Launcher", + QFileInfo(self.gameDirectory(), "Binaries/Win32/BmLauncher.exe"), + ), + ] diff --git a/libs/basic_games/games/game_assettocorsa.py b/libs/basic_games/games/game_assettocorsa.py new file mode 100644 index 0000000..da70669 --- /dev/null +++ b/libs/basic_games/games/game_assettocorsa.py @@ -0,0 +1,18 @@ +from ..basic_game import BasicGame + + +class AssettoCorsaGame(BasicGame): + Name = "Assetto Corsa Support Plugin" + Author = "Deorder" + Version = "0.0.1" + + GameName = "Assetto Corsa" + GameShortName = "ac" + GameBinary = r"AssettoCorsa.exe" + GameDataPath = r"" + GameSteamId = 244210 + GameDocumentsDirectory = "%DOCUMENTS%/Assetto Corsa" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Assetto-Corsa" + ) diff --git a/libs/basic_games/games/game_baldursgate3.py b/libs/basic_games/games/game_baldursgate3.py new file mode 100644 index 0000000..4898696 --- /dev/null +++ b/libs/basic_games/games/game_baldursgate3.py @@ -0,0 +1,239 @@ +import datetime +import difflib +import os +import shutil +from functools import cached_property +from pathlib import Path +from typing import Any + +from PyQt6.QtCore import QLoggingCategory, qDebug, qInfo + +import mobase + +from ..basic_features import BasicGameSaveGameInfo, BasicLocalSavegames +from ..basic_game import BasicGame +from .baldursgate3 import bg3_file_mapper + + +class BG3Game(BasicGame, bg3_file_mapper.BG3FileMapper): + Name = "Baldur's Gate 3 Plugin" + Author = "daescha" + Version = "0.1.0" + GameName = "Baldur's Gate 3" + GameShortName = "baldursgate3" + GameNexusName = "baldursgate3" + GameValidShortNames = ["bg3"] + GameLauncher = "Launcher/LariLauncher.exe" + GameBinary = "bin/bg3.exe" + GameDataPath = "" + GameDocumentsDirectory = ( + "%USERPROFILE%/AppData/Local/Larian Studios/Baldur's Gate 3" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/PlayerProfiles/Public/Savegames/Story" + GameSaveExtension = "lsv" + GameNexusId = 3474 + GameSteamId = 1086940 + GameGogId = 1456460669 + + def __init__(self): + BasicGame.__init__(self) + from .baldursgate3 import bg3_utils + + self.utils = bg3_utils.BG3Utils(self.name()) + bg3_file_mapper.BG3FileMapper.__init__( + self, self.utils, self.documentsDirectory + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self.utils.init(organizer) + from .baldursgate3 import bg3_data_checker, bg3_data_content + + self._register_feature(bg3_data_checker.BG3ModDataChecker()) + self._register_feature(bg3_data_content.BG3DataContent()) + self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".webp"))) + self._register_feature(BasicLocalSavegames(self)) + organizer.onAboutToRun(self.utils.construct_modsettings_xml) + organizer.onFinishedRun(self._on_finished_run) + organizer.onUserInterfaceInitialized(self.utils.on_user_interface_initialized) + organizer.modList().onModInstalled(self.utils.on_mod_installed) + organizer.onPluginSettingChanged(self.utils.on_settings_changed) + return True + + def settings(self): + base_settings = super().settings() + custom_settings = [ + mobase.PluginSetting( + "force_load_dlls", + "Force load all dlls detected in active mods. Removes the need for 'Native Mod Loader' and similar mods.", + True, + ), + mobase.PluginSetting( + "log_diff", + "Log a diff of the modsettings.xml file before and after the game runs to check for differences.", + False, + ), + mobase.PluginSetting( + "delete_levelcache_folders_older_than_x_days", + "Maximum number of days a file in overwrite/LevelCache is allowed to exist before being deleted " + "after the executable finishes. Set to negative to disable.", + 3, + ), + mobase.PluginSetting( + "autobuild_paks", + "Autobuild folders likely to be PAK folders with every run of an executable.", + True, + ), + mobase.PluginSetting( + "remove_extracted_metadata", + "Remove extracted meta.lsx files when they are no longer needed.", + True, + ), + mobase.PluginSetting( + "extract_full_package", + "Extract the full pak when parsing metadata, instead of just meta.lsx.", + False, + ), + mobase.PluginSetting( + "convert_yamls_to_json", + "Convert YAMLs to JSONs when executable runs. Allows one to configure ScriptExtender and related mods with YAML files.", + False, + ), + ] + for setting in custom_settings: + setting.description = self.utils.tr(setting.description) + base_settings.append(setting) + return base_settings + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + f"{self.gameName()}: DX11", + self.gameDirectory().absoluteFilePath("bin/bg3_dx11.exe"), + ), + mobase.ExecutableInfo( + f"{self.gameName()}: Vulkan", + self.gameDirectory().absoluteFilePath(self.binaryName()), + ), + mobase.ExecutableInfo( + "Larian Launcher", + self.gameDirectory().absoluteFilePath(self.getLauncherName()), + ), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + try: + efls = super().executableForcedLoads() + except AttributeError: + efls = [] + if self.utils.force_load_dlls: + qInfo("detecting dlls in enabled mods") + libs: set[str] = set() + tree: mobase.IFileTree | mobase.FileTreeEntry | None = ( + self._organizer.virtualFileTree().find("bin") + ) + if type(tree) is not mobase.IFileTree: + return efls + + def find_dlls( + _: Any, entry: mobase.FileTreeEntry + ) -> mobase.IFileTree.WalkReturn: + relpath = entry.pathFrom(tree) + if ( + relpath + and entry.hasSuffix("dll") + and relpath not in self._base_dlls + ): + libs.add(relpath) + return mobase.IFileTree.WalkReturn.CONTINUE + + tree.walk(find_dlls) + exes = self.executables() + qDebug(f"dlls to force load: {libs}") + efls = efls + [ + mobase.ExecutableForcedLoadSetting( + exe.binary().fileName(), lib + ).withEnabled(True) + for lib in libs + for exe in exes + ] + return efls + + def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: + return mobase.LoadOrderMechanism.PLUGINS_TXT + + @cached_property + def _base_dlls(self) -> set[str]: + base_bin = Path(self.gameDirectory().absoluteFilePath("bin")) + return {str(f.relative_to(base_bin)) for f in base_bin.glob("*.dll")} + + def _on_finished_run(self, exec_path: str, exit_code: int): + if "bin/bg3" not in exec_path: + return + cat = QLoggingCategory.defaultCategory() + self.utils.log_dir.mkdir(parents=True, exist_ok=True) + if ( + cat is not None + and cat.isDebugEnabled() + and self.utils.log_diff + and self.utils.modsettings_backup.exists() + and self.utils.modsettings_path.exists() + ): + for x in difflib.unified_diff( + self.utils.modsettings_backup.open().readlines(), + self.utils.modsettings_path.open().readlines(), + fromfile=str(self.utils.modsettings_backup), + tofile=str(self.utils.modsettings_path), + lineterm="", + ): + qDebug(x) + moved: dict[str, str] = {} + for path in self.utils.overwrite_path.rglob("*.log"): + try: + moved[str(path.relative_to(Path.home()))] = str( + (self.utils.log_dir / path.name).relative_to(Path.home()) + ) + path.replace(self.utils.log_dir / path.name) + except PermissionError as e: + qDebug(str(e)) + for path in self.utils.overwrite_path.rglob("*log.txt"): + dest = self.utils.log_dir / path.name + if path.name == "log.txt": + dest = self.utils.log_dir / f"{path.parent.name}-{path.name}" + try: + moved[str(path.relative_to(Path.home()))] = str( + dest.relative_to(Path.home()) + ) + path.replace(dest) + except PermissionError as e: + qDebug(str(e)) + if cat is not None and cat.isDebugEnabled() and len(moved) > 0: + qDebug(f"moved log files to logs dir: {moved}") + days = self.utils.get_setting("delete_levelcache_folders_older_than_x_days") + if type(days) is int and days >= 0: + cutoff_time = datetime.datetime.now() - datetime.timedelta(days=days) + qDebug(f"cleaning folders in overwrite/LevelCache older than {cutoff_time}") + removed: set[Path] = set() + for path in self.utils.overwrite_path.glob("LevelCache/*"): + if ( + datetime.datetime.fromtimestamp(os.path.getmtime(path)) + < cutoff_time + ): + shutil.rmtree(path, ignore_errors=True) + removed.add(path) + if cat is not None and cat.isDebugEnabled() and len(removed) > 0: + qDebug( + f"cleaned the following folders due to them being older than {cutoff_time}: {removed}" + ) + for fdir in {self.utils.overwrite_path, self.doc_path}: + removed: set[Path] = set() + for folder in sorted(list(fdir.walk(top_down=False)))[:-1]: + try: + folder[0].rmdir() + removed.add(folder[0].relative_to(Path.home())) + except OSError: + pass + if cat is not None and cat.isDebugEnabled() and len(removed) > 0: + qDebug( + f"cleaned empty dirs from {fdir.relative_to(Path.home())} {removed}" + ) diff --git a/libs/basic_games/games/game_blackandwhite2.py b/libs/basic_games/games/game_blackandwhite2.py new file mode 100644 index 0000000..5d9f60b --- /dev/null +++ b/libs/basic_games/games/game_blackandwhite2.py @@ -0,0 +1,341 @@ +import datetime +import os +import struct +import time +from collections.abc import Mapping +from pathlib import Path +from typing import BinaryIO + +from PyQt6.QtCore import QDateTime, QDir, QFile, QFileInfo + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_game import BasicGame + + +class BlackAndWhite2ModDataChecker(mobase.ModDataChecker): + _validFolderTree = { + "<black & white 2>": ["audio", "data", "plugins", "scripts"], + "audio": ["dialogue", "music", "sfx"], + "music": [ + "buildingmusic", + "chant", + "cutscene", + "dynamic music", + "epicspell", + "townalignment", + ], + "sfx": ["atmos", "creature", "game", "script", "spells", "video", "grass"], + "data": [ + "art", + "balance", + "ctr", + "effects", + "encryptedshaders", + "font", + "handdemo", + "interface", + "landscape", + "light particle effects", + "lipsync", + "physics", + "sfx", + "shaders", + "symbols", + "text", + "textures", + "tutorial avi", + "visualeffects", + "weathersystem", + "zones", + ], + "art": [ + "binary_anim_libs", + "binary_animations", + "features", + "models", + "skins", + "textures", + "water", + ], + "ctr": [ + "badvisor_evil", + "badvisor_good", + "bape", + "bgorilla", + "bhand", + "blion", + "btiger", + "bwolf", + "damage", + "siren", + ], + "font": ["asian"], + "asian": ["korean", "traditional chinese"], + "landscape": [ + "aztec", + "bw2", + "egyptian", + "generic", + "greek", + "japanese", + "norse", + "skysettings", + ], + "tutorial avi": ["placeholder", "stills"], + "visualeffects": ["textures"], + "scripts": ["bw2"], + } + _validFileLocation = { + "<black & white 2>": ["exe", "dll", "ico", "png", "jpeg", "jpg"] + } + _mapFile = ["chl", "bmp", "bwe", "ter", "pat", "xml", "wal", "txt"] + _fileIgnore = ["readme", "read me", "meta.ini", "thumbs.db", "backup", ".png"] + + def fix(self, filetree: mobase.IFileTree): + toMove: list[tuple[mobase.FileTreeEntry, str]] = [] + for entry in filetree: + if any([sub in entry.name().casefold() for sub in self._fileIgnore]): + continue + elif entry.suffix() == "chl": + toMove.append((entry, "/Scripts/BW2/")) + elif entry.suffix() == "bmp": + toMove.append((entry, "/Data/")) + elif entry.suffix() == "txt": + toMove.append((entry, "/Scripts/")) + else: + toMove.append((entry, "/Data/landscape/BW2/")) + + for entry, path in toMove: + filetree.move(entry, path, policy=mobase.IFileTree.MERGE) + + return filetree + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # qInfo("Data validation start") + root = filetree + unpackagedMap = False + + for entry in filetree: + entryName = entry.name().casefold() + canIgnore = any([sub in entryName for sub in self._fileIgnore]) + if not canIgnore: + parent = entry.parent() + if parent is not None: + if parent != root: + parentName = parent.name().casefold() + else: + # qInfo(str(entryName)) + parentName = "<black & white 2>" + + if not entry.isDir(): + if parentName in self._validFileLocation.keys(): + if ( + entry.suffix() + not in self._validFileLocation[parentName] + ): + if ( + entry.suffix() in self._mapFile + or entryName == "map.txt" + ): + unpackagedMap = True + else: + return mobase.ModDataChecker.INVALID + else: + unpackagedMap = False + if parentName in self._validFolderTree.keys(): + if entryName not in self._validFolderTree[parentName]: + return mobase.ModDataChecker.INVALID + + # qInfo(str(unpackagedMap)) + if unpackagedMap: + return mobase.ModDataChecker.FIXABLE + else: + return mobase.ModDataChecker.VALID + + +class BlackAndWhite2SaveGame(BasicGameSaveGame): + _saveInfLayout = { + "start": [0x00000000, 0x00000004], + "name": [0x00000004, 0x0000002C], + "empty": [0x0000002C, 0x00000104], + "land": [0x00000104, 0x00000108], + "date": [0x00000108, 0x00000110], + "empty1": [0x00000110, 0x00000114], + "elapsed": [0x00000114, 0x00000118], + "empty2": [0x00000108, 0x0000011C], + } + + def __init__(self, filepath: Path): + super().__init__(filepath) + self._filepath = Path(filepath) + self.name: str = "" + self.land: int = -1 + self.elapsed: int = 0 + self.lastsave: int = 0 + with open(self._filepath.joinpath("SaveGame.inf"), "rb") as info: + # Name embedded in "SaveGame.inf" with UTF-16 encoding + self.name = self.readInf(info, "name").decode("utf-16") + # Land number embedded in "SaveGame.inf" as an int written in binary + self.land = int.from_bytes(self.readInf(info, "land"), "little") + # Getting elapsed time in second + self.elapsed = int.from_bytes(self.readInf(info, "elapsed"), "little") + # Getting date in 100th of nanosecond need to convert NT time + # to UNIX time and offset localtime + self.lastsave = int( + ( + struct.unpack("q", self.readInf(info, "date"))[0] / 10000 + - 11644473600000 + ) + - (time.localtime().tm_gmtoff * 1000) + ) + info.close() + + def readInf(self, inf: BinaryIO, key: str): + inf.seek(self._saveInfLayout[key][0]) + return inf.read(self._saveInfLayout[key][1] - self._saveInfLayout[key][0]) + + def allFiles(self) -> list[str]: + files = [str(file) for file in self._filepath.glob("./*")] + files.append(str(self._filepath)) + return files + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromMSecsSinceEpoch(self.lastsave) + + def getElapsed(self) -> str: + return str(datetime.timedelta(seconds=self.elapsed)) + + def getName(self) -> str: + return self.name + + def getLand(self) -> str: + return str(self.land) + + def getSaveGroupIdentifier(self): + return self._filepath.parent.parent.name + + +def getMetadata(savepath: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + assert isinstance(save, BlackAndWhite2SaveGame) + return { + "Name": save.getName(), + "Profile": save.getSaveGroupIdentifier(), + "Land": save.getLand(), + "Saved at": format_date(save.getCreationTime()), + "Elapsed time": save.getElapsed(), + } + + +PSTART_MENU = ( + str(os.getenv("ProgramData")) + "\\Microsoft\\Windows\\Start Menu\\Programs" +) + + +class BlackAndWhite2Game(BasicGame): + Name = "Black & White 2 Support Plugin" + Author = "Ilyu" + Version = "1.0.1" + + GameName = "Black & White 2" + GameShortName = "BW2" + GameNexusName = "blackandwhite2" + GameDataPath = "%GAME_PATH%" + GameBinary = "white.exe" + GameDocumentsDirectory = "%DOCUMENTS%/Black & White 2" + GameSavesDirectory = "%GAME_DOCUMENTS%/Profiles" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Black-&-White-2" + ) + + _program_link = PSTART_MENU + "\\Black & White 2\\Black & White® 2.lnk" + + def init(self, organizer: mobase.IOrganizer) -> bool: + BasicGame.init(self, organizer) + + self._register_feature(BlackAndWhite2ModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + self._register_feature( + BasicGameSaveGameInfo(get_metadata=getMetadata, max_width=400) + ) + return True + + def detectGame(self): + super().detectGame() + + program_path = Path(self._program_link) + if program_path.exists(): + installation_path = Path(QFileInfo(self._program_link).symLinkTarget()) + if installation_path.exists(): + self.setGamePath(installation_path.parent) + + return + + def executables(self) -> list[mobase.ExecutableInfo]: + execs = super().executables() + + """ + A bat file to load modded executables from VFS. + """ + workaroundPath = self._gamePath + "/" + self.GameBinary[:-4] + ".bat" + + try: + workaround = open(workaroundPath, "rt") + except FileNotFoundError: + with open(workaroundPath, "wt") as workaround: + workaround.write('start "" "' + self.GameBinary + '"') + workaround.close() + + execs.append( + mobase.ExecutableInfo( + self.GameShortName + " Modded Exec", QFileInfo(workaroundPath) + ) + ) + + return execs + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + profiles: list[Path] = [] + for path in Path(folder.absolutePath()).glob("*/Saved Games/*"): + if ( + path.name == "Autosave" + or path.name == "Pictures" + or "_invalid" in path.name + ): + continue + if path.is_dir(): + saveFolder = QDir(str(path)) + if not saveFolder.exists("SaveGame.inf"): + savePath = saveFolder.absolutePath() + QFile.rename(savePath, savePath + "_invalid") + continue + else: + continue + + profiles.append(path) + + return [BlackAndWhite2SaveGame(path) for path in profiles] + + +class BOTGGame(BlackAndWhite2Game): + Name = "Black & White 2 Battle of the Gods Support Plugin" + + GameName = "Black & White 2 Battle of the Gods" + GameShortName = "BOTG" + GameBinary = "BattleOfTheGods.exe" + GameDocumentsDirectory = "%DOCUMENTS%/Black & White 2 - Battle of the Gods" + GameSavesDirectory = "%GAME_DOCUMENTS%/Profiles" + + _program_link = ( + PSTART_MENU + "\\Black & White 2 Battle of the Gods" + "\\Black & White® 2 Battle of the Gods.lnk" + ) diff --git a/libs/basic_games/games/game_bladeandsorcery.py b/libs/basic_games/games/game_bladeandsorcery.py new file mode 100644 index 0000000..bc689eb --- /dev/null +++ b/libs/basic_games/games/game_bladeandsorcery.py @@ -0,0 +1,98 @@ +import json +from collections.abc import Mapping +from pathlib import Path + +from PyQt6.QtCore import QDateTime, QDir + +import mobase + +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_game import BasicGame + + +class BaSSaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + with open(self._filepath, "rb") as save: + save_data = json.load(save) + self._gameMode = save_data["mode"]["saveData"]["gameModeId"] + self._gender = ( + "Male" + if save_data["customization"]["creatureId"] == "PlayerDefaultMale" + else "Female" + ) + self._ethnicity = save_data["customization"]["ethnicGroupId"] + h, m, s = save_data["playTime"].split(":") + self._elapsed = (float(h), int(m), float(s)) + f_stat = self._filepath.stat() + self._created = f_stat.st_birthtime + self._modified = f_stat.st_mtime + + def getName(self) -> str: + return f"{self.getPlayerSlug()} - {self._gameMode}" + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._created)) + + def getModifiedTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._modified)) + + def getPlayerSlug(self) -> str: + return f"{self._gender} {self._ethnicity}" + + def getElapsed(self) -> str: + return ( + f"{self._elapsed[0]} hours, " + f"{self._elapsed[1]} minutes, " + f"{int(self._elapsed[2])} seconds" + ) + + def getGameMode(self) -> str: + return self._gameMode + + +def bas_parse_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + assert isinstance(save, BaSSaveGame) + return { + "Character": save.getPlayerSlug(), + "Game Mode": save.getGameMode(), + "Created At": format_date(save.getCreationTime()), + "Last Saved": format_date(save.getModifiedTime()), + "Session Duration": save.getElapsed(), + } + + +class BaSGame(BasicGame): + Name = "Blade & Sorcery Plugin" + Author = "R3z Shark & Silarn & Jonny_Bro" + Version = "0.5.1" + + GameName = "Blade & Sorcery" + GameShortName = "bladeandsorcery" + GameBinary = "BladeAndSorcery.exe" + GameDataPath = r"BladeAndSorcery_Data\\StreamingAssets\\Mods" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/BladeAndSorcery" + GameSavesDirectory = "%GAME_DOCUMENTS%/Saves/Default" + GameSaveExtension = "chr" + GameSteamId = 629730 + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Blade-&-Sorcery" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + BasicGame.init(self, organizer) + self._register_feature( + BasicGameSaveGameInfo(get_metadata=bas_parse_metadata, max_width=400) + ) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + BaSSaveGame(path) for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_borderlands1.py b/libs/basic_games/games/game_borderlands1.py new file mode 100644 index 0000000..4b90b51 --- /dev/null +++ b/libs/basic_games/games/game_borderlands1.py @@ -0,0 +1,153 @@ +import re + +import mobase +from mobase import FileTreeEntry, IFileTree, ModDataChecker + +from ..basic_features import BasicModDataChecker +from ..basic_game import BasicGame + +_extention_pattern = re.compile("\\.(upk|umap|u|int|dll|exe)$", re.I) +_mapslot_pattern = re.compile("^Mapslot\\d\\d?\\.umap$", re.I) + +_mod_dirs = { + "Binaries".casefold(): "/", + "WillowGame".casefold(): "/", + "CookedPC".casefold(): "WillowGame/", + "Localization".casefold(): "WillowGame/", + "Maps".casefold(): "WillowGame/CookedPC/", +} + +_slots_path = "WillowGame/CookedPC/Maps/MapSlots" + + +def _check_filetree( + filetree: IFileTree | FileTreeEntry, recurse: bool +) -> ModDataChecker.CheckReturn: + if filetree.isFile(): + if _extention_pattern.search(filetree.name()): + if _mapslot_pattern.search(filetree.name()): + return ModDataChecker.FIXABLE + else: + return ModDataChecker.INVALID + elif recurse and isinstance(filetree, IFileTree): + status = ModDataChecker.VALID + for child in filetree: + child_status = _check_filetree(child, True) + if child_status is ModDataChecker.INVALID: + return ModDataChecker.INVALID + elif child_status is ModDataChecker.FIXABLE: + status = ModDataChecker.FIXABLE + return status + + return ModDataChecker.VALID + + +def _get_nest(filetree: IFileTree) -> IFileTree | None: + children = tuple(filetree) + if ( + len(children) == 1 + and children[0].isDir() + and isinstance(children[0], IFileTree) + and _mod_dirs.get(children[0].name().casefold()) is None + ): + return children[0] + return None + + +def _get_slotstree(roottree: IFileTree) -> IFileTree: + slotstree: IFileTree | None = roottree.find(_slots_path) # type: ignore + if slotstree is None: + slotstree = roottree.addDirectory(_slots_path) + return slotstree + + +def _fix_mapslots(filetree: IFileTree, roottree: IFileTree) -> None: + for child in filetree: + if child.isDir() and isinstance(child, IFileTree): + _fix_mapslots(child, roottree) + elif _mapslot_pattern.search(child.name()): + child.moveTo(_get_slotstree(roottree)) + + +class Borderlands1ModDataChecker(BasicModDataChecker): + def dataLooksValid(self, filetree: IFileTree) -> ModDataChecker.CheckReturn: + parent = filetree.parent() + if parent is not None: + return self.dataLooksValid(parent) + + status = ModDataChecker.VALID + + nest = _get_nest(filetree) + if nest is not None: + status = ModDataChecker.FIXABLE + filetree = nest + + if _check_filetree(filetree, False) is ModDataChecker.INVALID: + return ModDataChecker.INVALID + + slotstree = filetree.find(_slots_path) + if slotstree is not None and not slotstree.isDir(): + return ModDataChecker.INVALID + + for child in filetree: + if child.isDir(): + destination = _mod_dirs.get(child.name().casefold()) + if destination is None: + child_status = _check_filetree(child, True) + if child_status is ModDataChecker.INVALID: + return ModDataChecker.INVALID + elif child_status is ModDataChecker.FIXABLE: + status = ModDataChecker.FIXABLE + elif destination != "/": + status = ModDataChecker.FIXABLE + elif _mapslot_pattern.search(child.name()): + status = ModDataChecker.FIXABLE + elif _extention_pattern.search(child.name()): + return ModDataChecker.INVALID + return status + + def fix(self, filetree: IFileTree) -> IFileTree: + nest = _get_nest(filetree) + if nest is not None: + conflict: FileTreeEntry | None = None + for child in tuple(nest): + if not child.moveTo(filetree): + conflict = child + conflict.detach() + filetree.remove(nest) + if conflict is not None: + conflict.moveTo(filetree) + + for child in tuple(filetree): + if child.isDir() and isinstance(child, IFileTree): + destination = _mod_dirs.get(child.name().casefold()) + if destination is None: + _fix_mapslots(child, filetree) + elif destination != "/": + filetree.move(child, destination) + elif _mapslot_pattern.search(child.name()): + child.moveTo(_get_slotstree(filetree)) + + return filetree + + +class Borderlands1Game(BasicGame): + Name = "Borderlands 1 Support Plugin" + Author = "Miner Of Worlds, RedxYeti, mopioid" + Version = "1.0.0" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(Borderlands1ModDataChecker()) + return True + + GameName = "Borderlands" + GameShortName = "Borderlands" + GameNexusName = "Borderlands GOTY" + GameSteamId = 8980 + GameBinary = "Binaries/Borderlands.exe" + GameDataPath = "." + GameSaveExtension = "sav" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/Borderlands/" + GameSavesDirectory = "%GAME_DOCUMENTS%/savedata" + GameIniFiles = "%GAME_DOCUMENTS%/WillowGame/Config" diff --git a/libs/basic_games/games/game_control.py b/libs/basic_games/games/game_control.py new file mode 100644 index 0000000..2662410 --- /dev/null +++ b/libs/basic_games/games/game_control.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class ControlGame(BasicGame): + Name = "Control Support Plugin" + Author = "Zash" + Version = "1.0.0" + + GameName = "Control" + GameShortName = "control" + GameNexusId = 2936 + GameSteamId = 870780 + GameGogId = 2049187585 + GameEpicId = "calluna" + GameBinary = "Control.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Control (Launcher)", + QFileInfo( + self.gameDirectory(), + self.binaryName(), + ), + ), + mobase.ExecutableInfo( + "Control DX11", + QFileInfo( + self.gameDirectory(), + "Control_DX11.exe", + ), + ), + mobase.ExecutableInfo( + "Control DX12", + QFileInfo( + self.gameDirectory(), + "Control_DX12.exe", + ), + ), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + try: + efls = super().executableForcedLoads() + except AttributeError: + efls = [] + + libraries = ["iphlpapi.dll", "xinput1_4.dll"] + efls.extend( + mobase.ExecutableForcedLoadSetting( + exe.binary().fileName(), lib + ).withEnabled(True) + for lib in libraries + for exe in self.executables() + ) + return efls diff --git a/libs/basic_games/games/game_cyberpunk2077.py b/libs/basic_games/games/game_cyberpunk2077.py new file mode 100644 index 0000000..ab44b8b --- /dev/null +++ b/libs/basic_games/games/game_cyberpunk2077.py @@ -0,0 +1,798 @@ +import filecmp +import json +import re +import shutil +import tempfile +import textwrap +from collections import Counter +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, TypeVar + +from PyQt6.QtCore import QDateTime, QDir, Qt, qCritical, qInfo, qWarning +from PyQt6.QtWidgets import ( + QCheckBox, + QMainWindow, + QMessageBox, + QProgressDialog, + QWidget, +) + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_game import BasicGame + + +class CyberpunkModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + delete=[ + "*.gif", + "*.jpg", + "*.jpeg", + "*.jxl", + "*.md", + "*.png", + "*.txt", + "*.webp", + ], + move={ + # archive and ArchiveXL + "*.archive": "archive/pc/mod/", + "*.xl": "archive/pc/mod/", + }, + valid=[ + "archive", + # redscript + "engine", + "r6", + "mods", # RedMod + "red4ext", + "bin", # CET etc. gets handled below + ], + ) + ) + + +def time_from_seconds(s: int | float) -> str: + m, s = divmod(int(s), 60) + h, m = divmod(int(m), 60) + return f"{h:02}:{m:02}:{s:02}" + + +def parse_cyberpunk_save_metadata(save_path: Path, save: mobase.ISaveGame): + metadata_file = save_path / "metadata.9.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file)["Data"]["metadata"] + name = meta_data["name"] + if name != (save_name := save.getName()): + name = f"{save_name} ({name})" + return { + "Name": name, + "Date": format_date(meta_data["timestampString"], "hh:mm:ss, d.M.yyyy"), + "Play Time": time_from_seconds(meta_data["playthroughTime"]), + "Quest": meta_data["trackedQuestEntry"], + "Level": int(meta_data["level"]), + "Street Cred": int(meta_data["streetCred"]), + "Life Path": meta_data["lifePath"], + "Difficulty": meta_data["difficulty"], + "Gender": f"{meta_data['bodyGender']} / {meta_data['brainGender']}", + "Game version": meta_data["buildPatch"], + } + except (FileNotFoundError, json.JSONDecodeError): + return None + + +class CyberpunkSaveGame(BasicGameSaveGame): + _name_file = "NamedSave.txt" # from mod: Named Saves + + def __init__(self, filepath: Path): + super().__init__(filepath) + try: # Custom name from Named Saves + with open(filepath / self._name_file) as file: + self._name = file.readline() + except FileNotFoundError: + self._name = "" + + def getName(self) -> str: + return self._name or super().getName() + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch( + int((self._filepath / "sav.dat").stat().st_mtime) + ) + + +@dataclass +class ModListFile: + list_path: Path + mod_search_pattern: str + reversed_priority: bool = False + """True: load order priority is reversed compared to MO (first mod has priority).""" + + +_MOD_TYPE = TypeVar("_MOD_TYPE") + + +class ModListFileManager(dict[_MOD_TYPE, ModListFile]): + """Manages modlist files for specific mod types.""" + + def __init__(self, organizer: mobase.IOrganizer, **kwargs: ModListFile) -> None: + self._organizer = organizer + super().__init__(**kwargs) + + def update_modlist( + self, mod_type: _MOD_TYPE, mod_files: list[str] | None = None + ) -> tuple[Path, list[str], list[str]]: + """ + Updates the mod list file for `mod_type` with the current load order. + Removes the file if it is not needed. + + Args: + mod_type: Which modlist to update. + mod_files (optional): By default mod files in order of `mod_type` priority + (respecting `self[mod_type].reversed_priority`). + + Returns: + `(modlist_path, new_mod_list, old_mod_list)` + """ + if mod_files is None: + mod_files = list(self.modfile_names(mod_type)) + modlist_path = self.absolute_modlist_path(mod_type) + old_modlist = ( + modlist_path.read_text().splitlines() if modlist_path.exists() else [] + ) + if not mod_files or len(mod_files) == 1: + # No load order required + if old_modlist: + qInfo(f"Removing {mod_type} load order {modlist_path}") + modlist_path.unlink() + return modlist_path, [], old_modlist + else: + qInfo(f'Updating {mod_type} load order "{modlist_path}" with: {mod_files}') + modlist_path.parent.mkdir(parents=True, exist_ok=True) + modlist_path.write_text("\n".join(mod_files)) + return modlist_path, mod_files, old_modlist + + def absolute_modlist_path(self, mod_type: _MOD_TYPE) -> Path: + modlist_path = self[mod_type].list_path + if not modlist_path.is_absolute(): + existing = self._organizer.findFiles(modlist_path.parent, modlist_path.name) + overwrite = self._organizer.overwritePath() + modlist_path = ( + Path(existing[0]) if (existing) else Path(overwrite, modlist_path) + ) + return modlist_path + + def modfile_names(self, mod_type: _MOD_TYPE) -> Iterable[str]: + """Get all file names from the `mod_type` in MOs load order + (reversed with `self[mod_type].reversed_priority = True`). + """ + yield from (file.name for file in self.modfiles(mod_type)) + + def modfiles(self, mod_type: _MOD_TYPE) -> Iterable[Path]: + """Get all files from the `mod_type` in MOs load order + (reversed with `self[mod_type].reversed_priority = True`). + """ + mod_search_pattern = self[mod_type].mod_search_pattern + for mod_path in self.active_mod_paths(self[mod_type].reversed_priority): + yield from mod_path.glob(mod_search_pattern) + + def active_mod_paths(self, reverse: bool = False) -> Iterable[Path]: + """Yield the path to active mods in MOs load order.""" + mods_path = Path(self._organizer.modsPath()) + modlist = self._organizer.modList() + mods_load_order = modlist.allModsByProfilePriority() + for mod in reversed(mods_load_order) if reverse else mods_load_order: + if modlist.state(mod) & mobase.ModState.ACTIVE: + yield mods_path / mod + + +class Cyberpunk2077Game(BasicGame): + Name = "Cyberpunk 2077 Support Plugin" + Author = "6788, Zash" + Version = "3.0.1" + + GameName = "Cyberpunk 2077" + GameShortName = "cyberpunk2077" + GameBinary = "bin/x64/Cyberpunk2077.exe" + GameLauncher = "REDprelauncher.exe" + GameDataPath = "%GAME_PATH%" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/CD Projekt Red/Cyberpunk 2077" + GameSavesDirectory = "%USERPROFILE%/Saved Games/CD Projekt Red/Cyberpunk 2077" + GameSaveExtension = "dat" + GameSteamId = 1091500 + GameGogId = 1423049311 + GameEpicId = "77f2b98e2cef40c8a7437518bf420e47" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Cyberpunk-2077" + ) + + # CET and RED4ext, relative to Cyberpunk2077.exe + _forced_libraries = ["version.dll", "winmm.dll"] + _crashreporter_path = "bin/x64/CrashReporter/CrashReporter.exe" + + _redmod_binary = Path("tools/redmod/bin/redMod.exe") + _redmod_log = Path("tools/redmod/bin/REDmodLog.txt") + _redmod_deploy_path = Path("r6/cache/modded/") + _redmod_deploy_args = "deploy -reportProgress" + """Deploy arguments for `redmod.exe`, -modlist=... is added.""" + + _parentWidget: QWidget + """Set with `_organizer.onUserInterfaceInitialized()`""" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(BasicLocalSavegames(self)) + self._register_feature( + BasicGameSaveGameInfo( + lambda p: Path(p or "", "screenshot.png"), + parse_cyberpunk_save_metadata, + ) + ) + self._register_feature(CyberpunkModDataChecker()) + + self._modlist_files = ModListFileManager[Literal["archive", "redmod"]]( + organizer, + archive=ModListFile( + Path("archive/pc/mod/modlist.txt"), + "archive/pc/mod/*.archive", + reversed_priority=bool(self._get_setting("reverse_archive_load_order")), + ), + redmod=ModListFile( + Path(self._redmod_deploy_path, "MO_REDmod_load_order.txt"), + "mods/*/", + reversed_priority=bool(self._get_setting("reverse_redmod_load_order")), + ), + ) + organizer.onAboutToRun(self._onAboutToRun) + organizer.onFinishedRun(self._onFinishedRun) + organizer.onPluginSettingChanged(self._on_settings_changed) + organizer.modList().onModInstalled(self._check_disable_crashreporter) + organizer.onUserInterfaceInitialized(self._on_user_interface_initialized) + return True + + def _on_settings_changed( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ): + if self.name() != plugin_name: + return + match setting: + case "reverse_archive_load_order": + self._modlist_files["archive"].reversed_priority = bool(new) + case "reverse_remod_load_order": + self._modlist_files["redmod"].reversed_priority = bool(new) + case "show_rootbuilder_conversion": + if new and (dialog := self._get_rootbuilder_conversion_dialog()): + dialog.open() # type: ignore + case _: + return + + def _on_user_interface_initialized(self, window: QMainWindow): + self._parentWidget = window + if not self.isActive(): + return + if dialog := self._get_rootbuilder_conversion_dialog(window): + dialog.open() # type: ignore + else: + self._check_disable_crashreporter() + + def _check_disable_crashreporter( + self, mod: mobase.IModInterface | None | Any = None + ): + """ + Disable Crashreporter with CET installed in VFS, since it crashes + when trying to resolve `version.dll`. + """ + if not self.isActive() or not self._get_setting("disable_crashreporter"): + return + cet_mod_name = self._find_cet_mod_name( + mod if isinstance(mod, mobase.IModInterface) else None + ) + if ( + cet_mod_name + and (cr_origin := self._organizer.getFileOrigins(self._crashreporter_path)) + and cr_origin[0] == "data" + ): + self._create_dummy_crashreporter_mod( + self._organizer.modList().priority(cet_mod_name) + 1 + ) + + def _find_cet_mod_name(self, mod: mobase.IModInterface | None = None) -> str: + """ + Find the mod containing `version.dll`. + + Args: + mod (optional): check the mods filetree instead. Defaults to None. + + Returns: + The mods name if `version.dll` is found, else ''. + """ + cet_mod_name = "" + if mod: + if mod.fileTree().find("bin/x64/version.dll"): + cet_mod_name = mod.name() + elif dll_origins := self._organizer.getFileOrigins("bin/x64/version.dll"): + cet_mod_name = dll_origins[0] + return cet_mod_name if cet_mod_name != "data" else "" + + def _create_dummy_crashreporter_mod(self, priority: int = 0): + """ + Disables CrashReporter by creating an empty dummy file to replace + `CrashReporter.exe`. + """ + mod_name = "disable CrashReporter (MO CET fix)" + modlist = self._organizer.modList() + if modlist.getMod(mod_name): + return + qInfo(f"CET VFS fix: creating mod {mod_name}") + new_mod = self._organizer.createMod(mobase.GuessedString(mod_name)) + if not new_mod: + return + mod_name = new_mod.name() + new_mod.setGameName(self.gameShortName()) + new_mod.setUrl(f"{self.getSupportURL()}#crashreporter") + crashReporter = Path(new_mod.absolutePath(), self._crashreporter_path) + crashReporter.parent.mkdir(parents=True) + crashReporter.touch() + + def callback(): + modlist.setActive(mod_name, True) + modlist.setPriority(mod_name, priority) + + self._organizer.onNextRefresh(callback, False) + self._organizer.refresh() + self._set_setting("disable_crashreporter", False) + + def iniFiles(self): + return ["UserSettings.json"] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + CyberpunkSaveGame(path.parent) + for path in Path(folder.absolutePath()).glob(f"**/*.{ext}") + ] + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "skipStartScreen", + ( + 'Skips the "Breaching..." start screen on game launch' + " (can also skip loading of GOG rewards)" + ), + False, + ), + mobase.PluginSetting( + "enforce_archive_load_order", + ( + "Enforce the current load order via" + " <code>archive/pc/mod/modlist.txt</code>" + ), + False, + ), + mobase.PluginSetting( + "reverse_archive_load_order", + ( + "Reverse MOs load order in" + " <code>archive/pc/mod/modlist.txt</code>" + " (first loaded mod wins = last one / highest prio in MO)" + ), + False, + ), + mobase.PluginSetting( + "enforce_redmod_load_order", + "Enforce the current load order on redmod deployment", + True, + ), + mobase.PluginSetting( + "reverse_redmod_load_order", + ( + "Reverse MOs load order on redmod deployment" + " (first loaded mod wins = last one / highest prio in MO)" + ), + False, + ), + mobase.PluginSetting( + "auto_deploy_redmod", + "Deploy redmod before game launch if necessary", + True, + ), + mobase.PluginSetting( + "clear_cache_after_game_update", + ( + 'Clears "overwrite/r6/cache/*" if the original game files changed' + " (after update)" + ), + True, + ), + mobase.PluginSetting( + "disable_crashreporter", + ( + "Creates a dummy mod/file to disable CrashReporter.exe, " + "which is not compatible with VFS version.dll, see wiki" + ), + True, + ), + mobase.PluginSetting( + "crash_message", + ("Show a crash message as replacement of disabled CrashReporter"), + True, + ), + mobase.PluginSetting( + "show_rootbuilder_conversion", + ( + "Shows a dialog to convert legacy RootBuilder mods to native MO mods," + " using force load libraries" + ), + True, + ), + ] + + def _get_setting(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self.name(), key) + + def _set_setting(self, key: str, value: mobase.MoVariant): + self._organizer.setPluginSetting(self.name(), key, value) + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + bin_path = game_dir.absoluteFilePath(self.binaryName()) + skip_start_screen = ( + "-skipStartScreen" if self._get_setting("skipStartScreen") else "" + ) + return [ + # Default, runs REDmod deploy if necessary + mobase.ExecutableInfo( + f"{game_name} (REDmod)", + bin_path, + ).withArgument(f"--launcher-skip -modded {skip_start_screen}"), + # Start game without REDmod + mobase.ExecutableInfo( + f"{game_name}", + bin_path, + ).withArgument(f"--launcher-skip {skip_start_screen}"), + # Deploy REDmods only + mobase.ExecutableInfo( + "REDmod", + self._get_redmod_binary(), + ).withArgument("deploy -reportProgress -force %modlist%"), + # Launcher + mobase.ExecutableInfo( + "REDprelauncher", + game_dir.absoluteFilePath(self.getLauncherName()), + ).withArgument(f"{skip_start_screen}"), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + exe = Path(self.binaryName()).name + return [ + mobase.ExecutableForcedLoadSetting(exe, lib).withEnabled(True) + for lib in self._forced_libraries + ] + + def _get_redmod_binary(self) -> Path: + """Absolute path to redmod binary""" + return Path(self.gameDirectory().absolutePath(), self._redmod_binary) + + def _onAboutToRun(self, app_path_str: str, wd: QDir, args: str) -> bool: + if not self.isActive(): + return True + app_path = Path(app_path_str) + if app_path == self._get_redmod_binary(): + if m := re.search(r"%modlist%", args, re.I): + # Manual deployment: replace %modlist% variable + ( + modlist_path, + modlist, + _, + ) = self._modlist_files.update_modlist("redmod") + modlist_param = f'-modlist="{modlist_path}"' if modlist else "" + args = f"{args[: m.start()]}{modlist_param}{args[m.end() :]}" + qInfo(f"Manual modlist deployment: replacing {m[0]}, new args = {args}") + self._check_redmod_result( + self._organizer.waitForApplication( + self._organizer.startApplication(app_path_str, [args], wd), + False, + ) + ) + return False # redmod with new args started + return True # No recursive redmod call + if ( + self._get_setting("auto_deploy_redmod") + and app_path == Path(self.gameDirectory().absolutePath(), self.binaryName()) + and "-modded" in args + and not self._check_redmod_result(self._deploy_redmod()) + ): + qWarning("Aborting game launch.") + return False # Auto deploy failed + self._map_cache_files() + if self._get_setting("enforce_archive_load_order"): + self._modlist_files.update_modlist("archive") + return True + + def _onFinishedRun(self, path: str, exit_code: int) -> None: + if not self._get_setting("crash_message"): + return + if path.endswith(self.binaryName()) and exit_code > 0: + crash_message = QMessageBox( + QMessageBox.Icon.Critical, + "Cyberpunk Crashed", + textwrap.dedent( + f""" + Cyberpunk crashed. Tips: + - disable mods (create backup of modlist or use new profile) + - clear overwrite or delete at least overwrite/r6/cache (to keep mod settings) + - check log files of CET/redscript/RED4ext (in overwrite) + - read [FAQ & Troubleshooting]({self.GameSupportURL}#faq--troubleshooting) + """ + ), + QMessageBox.StandardButton.Ok, + self._parentWidget, + ) + crash_message.setTextFormat(Qt.TextFormat.MarkdownText) + hide_cb = QCheckBox("&Do not show again*", crash_message) + hide_cb.setToolTip(f"Settings/Plugins/{self.name()}/crash_message") + crash_message.setCheckBox(hide_cb) + crash_message.open( # type: ignore + lambda: hide_cb.isChecked() + and self._set_setting("crash_message", False) + ) + + def _check_redmod_result(self, result: tuple[bool, int]) -> bool: + if result == (True, 0): + return True + if result[1] < 0: + qWarning(f"REDmod deployment aborted (exit code {result[1]}).") + else: + qCritical( + f"REDmod deployment failed with exit code {result[1]} !" + f" Check {Path('GAME_FOLDER/', self._redmod_log)}" + ) + return False + + def _deploy_redmod(self) -> tuple[bool, int]: + """Deploys redmod. Clears deployed files if no redmods are active. + Recreates deployed files to force load order when necessary. + + Returns: + (success?, exit code) + """ + # Add REDmod load order if none is specified + redmod_list = list(self._modlist_files.modfile_names("redmod")) + if not redmod_list: + qInfo("Cleaning up redmod deployed files") + self._clean_deployed_redmod() + return True, 0 + args = self._redmod_deploy_args + if self._get_setting("enforce_redmod_load_order"): + modlist_path, _, old_redmods = self._modlist_files.update_modlist( + "redmod", redmod_list + ) + if ( + Counter(redmod_list) == Counter(old_redmods) + and not redmod_list == old_redmods + ): + # Only load order changed: recreate redmod deploys + # Fix for redmod not detecting change of load order. + # Faster than -force https://github.com/E1337Kat/cyberpunk2077_ext_redux/issues/297 # noqa: E501 + qInfo("Redmod order changed, recreate deployed files") + self._clean_deployed_redmod(modlist_path) + qInfo(f"Deploying redmod with modlist: {modlist_path}") + args += f' -modlist="{modlist_path}"' + else: + qInfo("Deploying redmod") + redmod_binary = self._get_redmod_binary() + return self._organizer.waitForApplication( + self._organizer.startApplication( + redmod_binary, [args], redmod_binary.parent + ), + False, + ) + + def _clean_deployed_redmod(self, modlist_path: Path | None = None): + """Delete all files from `_redmod_deploy_path` except for `modlist_path`.""" + for file in self._organizer.findFiles(self._redmod_deploy_path, "*"): + file_path = Path(file) + if modlist_path is None or file_path != modlist_path: + file_path.unlink() + + def _map_cache_files(self): + """ + Copy cache files (`final.redscript` etc.) to overwrite to catch + overwritten game files. + """ + data_path = Path(self.dataDirectory().absolutePath()) + overwrite_path = Path(self._organizer.overwritePath()) + data_cache_path = data_path / "r6/cache" + overwrite_cache_path = overwrite_path / "r6/cache" + cache_files = ( + file.relative_to(data_path) for file in data_cache_path.glob("*") + ) + if self._get_setting("clear_cache_after_game_update") and any( + self._is_cache_file_updated(file, data_path) for file in cache_files + ): + qInfo('Updated game files detected, clearing "overwrite/r6/cache/*"') + try: + shutil.rmtree(overwrite_cache_path) + except FileNotFoundError: + pass + qInfo('Updating "r6/cache" in overwrite') + shutil.copytree(data_cache_path, overwrite_cache_path, dirs_exist_ok=True) + else: + for file in self._unmapped_cache_files(data_path): + qInfo(f'Copying "{file}" to overwrite (to catch file overwrites)') + dst = overwrite_path / file + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(data_path / file, dst) + + def _is_cache_file_updated(self, file: Path, data_path: Path) -> bool: + """Checks if a cache file is updated (in game dir). + + Args: + file: Relative to data dir. + """ + game_file = data_path.absolute() / file + mapped_files = self._organizer.findFiles(file.parent, file.name) + return bool( + mapped_files + and (mapped_file := mapped_files[0]) + and not ( + game_file.samefile(mapped_file) + or filecmp.cmp(game_file, mapped_file) + or ( # different backup file + ( + backup_files := self._organizer.findFiles( + file.parent, f"{file.name}.bk" + ) + ) + and filecmp.cmp(game_file, backup_files[0]) + ) + ) + ) + + def _unmapped_cache_files(self, data_path: Path) -> Iterable[Path]: + """Yields unmapped cache files relative to `data_path`.""" + for file in self._organizer.findFiles("r6/cache", "*"): + try: + yield Path(file).absolute().relative_to(data_path) + except ValueError: + continue + + def _get_rootbuilder_conversion_dialog( + self, parent_widget: QWidget | None = None + ) -> QMessageBox | None: + """ + Dialog to convert any mods with `root` folder, if applicable. + CET and RED4ext work with forced load libraries since ~ Cyberpunk v2.12, + making RootBuilder unnecessary. + """ + setting = "show_rootbuilder_conversion" + if not self.isActive() or not self._get_setting(setting): + return None + if not ( + (root_folder := self._organizer.virtualFileTree().find("root")) + and root_folder.isDir() + ): + return None + parent_widget = parent_widget or self._parentWidget + message_box = QMessageBox( + QMessageBox.Icon.Question, + "RootBuilder obsolete", + textwrap.dedent( + """ + Mod Organizer now supports Cyberpunk Engine Tweaks (CET) and + RED4ext native via forced load libraries, making RootBuilder + unnecessary. + + Do you want to convert all mods with a `root` folder now? + <br/>This usually only affects CET, RED4ext and overwrite. + + You can disable RootBuilder afterwards. + """ + ), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + parent_widget, + ) + message_box.setTextFormat(Qt.TextFormat.MarkdownText) + checkbox = QCheckBox("&Do not show again*", parent_widget) + checkbox.setChecked(True) + checkbox.setToolTip(f"Settings/Plugins/{self.name()}/{setting}") + message_box.setCheckBox(checkbox) + + def accept_callback(): + if unfolded_mods := unfold_root_folders(self._organizer, parent_widget): + n_mods = len(unfolded_mods) + info = QMessageBox( + QMessageBox.Icon.Information, + "Root mods converted", + ( + f"{n_mods} mod{'s' if n_mods > 1 else ''} converted." + "You can disable RootBuilder in the settings now." + ), + parent=parent_widget, + ) + info.setDetailedText(f"Converted mods:\n{'\n'.join(unfolded_mods)}") + info.open() # type: ignore + + message_box.accepted.connect(accept_callback) # type: ignore + + def finished_callback(): + if checkbox.isChecked(): + self._set_setting(setting, False) + self._check_disable_crashreporter() + + message_box.finished.connect(finished_callback) # type: ignore + return message_box + + +def unfold_root_folders( + organizer: mobase.IOrganizer, parent_widget: QWidget | None = None +) -> list[str]: + """Unfolds (RootBuilders) root folders of all mods (excluding backups).""" + mods = organizer.modList().allMods() + progress = None + unfolded_mods: list[str] = [] + if parent_widget: + progress = QProgressDialog( + "Merging/unfolding root folders...", + "Abort", + 0, + len(mods), + parent_widget, + ) + progress.setWindowModality(Qt.WindowModality.WindowModal) + for i, mod_name in enumerate(mods): + if progress: + if progress.wasCanceled(): + break + progress.setValue(i) + if mod_name == "data": + continue + mod = organizer.modList().getMod(mod_name) + if mod.isBackup() or mod.isSeparator() or mod.isForeign(): + continue + root_folder = mod.fileTree().find("root") + if root_folder is None or not root_folder.isDir(): + continue + qInfo(f"Merging root folder of {mod_name}") + mod_path = Path(mod.absolutePath()) + root_folder_path = mod_path / "root" + unfold_folder(root_folder_path) + unfolded_mods.append(mod_name) + if progress: + progress.setValue(len(mods)) + organizer.refresh() + return unfolded_mods + + +def unfold_folder(src_path: Path): + """ + Unfolds a folder (`parent/src/* -> parent/*`), overwriting existing files/folders. + Preserves a subfolder with same name (`parent/src/src -> parent/src`). + """ + parent = src_path.parent + if (src_path / src_path.name).exists(): + # Contains a file/folder with same name + with tempfile.TemporaryDirectory(dir=parent) as temp_folder: + src_path = src_path.rename(parent / temp_folder / src_path.name) + shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True) + else: + shutil.copytree(src_path, parent, symlinks=True, dirs_exist_ok=True) + shutil.rmtree(src_path) diff --git a/libs/basic_games/games/game_da2.py b/libs/basic_games/games/game_da2.py new file mode 100644 index 0000000..9d481e7 --- /dev/null +++ b/libs/basic_games/games/game_da2.py @@ -0,0 +1,35 @@ +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DA2Game(BasicGame): + Name = "Dragon Age 2 Support Plugin" + Author = "Patchier" + + GameName = "Dragon Age 2" + GameShortName = "dragonage2" + GameBinary = r"bin_ship\DragonAge2.exe" + GameDataPath = r"%DOCUMENTS%\BioWare\Dragon Age 2\packages\core\override" + GameSavesDirectory = r"%DOCUMENTS%\BioWare\Dragon Age 2\Characters" + GameSaveExtension = "das" + GameSteamId = 1238040 + GameOriginManifestIds = ["OFB-EAST:59474", "DR:201797000"] + GameOriginWatcherExecutables = ("DragonAge2.exe",) + GameEaDesktopId = [70784, 1002980] + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dragon-Age-II" + ) + + def version(self): + # Don't forget to import mobase! + return mobase.VersionInfo(1, 0, 1, mobase.ReleaseType.FINAL) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo(lambda s: s.parent.joinpath("screen.dds")) + ) + return True diff --git a/libs/basic_games/games/game_daggerfallunity.py b/libs/basic_games/games/game_daggerfallunity.py new file mode 100644 index 0000000..6c170f1 --- /dev/null +++ b/libs/basic_games/games/game_daggerfallunity.py @@ -0,0 +1,56 @@ +import mobase + +from ..basic_game import BasicGame + + +class DaggerfallUnityModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "biogs", + "docs", + "factions", + "fonts", + "mods", + "questpacks", + "quests", + "sound", + "soundfonts", + "spellicons", + "tables", + "text", + "textures", + "worlddata", + "aa", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class DaggerfallUnityGame(BasicGame): + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(DaggerfallUnityModDataChecker()) + return True + + Name = "Daggerfall Unity Support Plugin" + Author = "HomerSimpleton" + Version = "1.0.0" + + GameName = "Daggerfall Unity" + GameShortName = "daggerfallunity" + GameBinary = "DaggerfallUnity.exe" + GameLauncher = "DaggerfallUnity.exe" + GameDataPath = "%GAME_PATH%/DaggerfallUnity_Data/StreamingAssets" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Daggerfall-Unity" + ) diff --git a/libs/basic_games/games/game_dao.py b/libs/basic_games/games/game_dao.py new file mode 100644 index 0000000..bc3f87e --- /dev/null +++ b/libs/basic_games/games/game_dao.py @@ -0,0 +1,31 @@ +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DAOriginsGame(BasicGame): + Name = "Dragon Age Origins Support Plugin" + Author = "Patchier" + Version = "1.1.1" + + GameName = "Dragon Age: Origins" + GameShortName = "dragonage" + GameBinary = r"bin_ship\DAOrigins.exe" + GameDataPath = r"%DOCUMENTS%\BioWare\Dragon Age\packages\core\override" + GameSavesDirectory = r"%DOCUMENTS%\BioWare\Dragon Age\Characters" + GameSaveExtension = "das" + GameSteamId = [17450, 47810] + GameGogId = 1949616134 + GameEaDesktopId = [70377, 70843] + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dragon-Age:-Origins" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo(lambda s: s.parent.joinpath("screen.dds")) + ) + return True diff --git a/libs/basic_games/games/game_darkestdungeon.py b/libs/basic_games/games/game_darkestdungeon.py new file mode 100644 index 0000000..5c3ae4e --- /dev/null +++ b/libs/basic_games/games/game_darkestdungeon.py @@ -0,0 +1,245 @@ +import json +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame, BasicGameSaveGame +from ..steam_utils import find_steam_path + + +class DarkestDungeonModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "activity_log", + "audio", + "campaign", + "colours", + "curios", + "cursors", + "dlc", + "dungeons", + "effects", + "fe_flow", + "fonts", + "fx", + "game_over", + "heroes", + "inventory", + "loading_screen", + "localization", + "loot", + "maps", + "modes", + "monsters", + "overlays", + "panels", + "props", + "raid", + "raid_result", + "scripts", + "scrolls", + "shaders", + "shared", + "trinkets", + "upgrades", + "video", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class DarkestDungeonSaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + dataPath = filepath.joinpath("persist.game.json") + self.name: str = "" + if self.isBinary(dataPath): + self.loadBinarySaveFile(dataPath) + else: + self.loadJSONSaveFile(dataPath) + + @staticmethod + def isBinary(dataPath: Path) -> bool: + with dataPath.open(mode="rb") as fp: + magic = fp.read(4) + # magic number in binary save files + return magic == b"\x01\xb1\x00\x00" + + def loadJSONSaveFile(self, dataPath: Path): + text = dataPath.read_text() + content = json.loads(text) + data = content["data"] + self.name = str(data["estatename"]) + + def loadBinarySaveFile(self, dataPath: Path): + # see https://github.com/robojumper/DarkestDungeonSaveEditor + with dataPath.open(mode="rb") as fp: + # read Header + + # skip to headerLength + fp.seek(8, 0) + headerLength = int.from_bytes(fp.read(4), "little") + if headerLength != 64: + raise ValueError("Header Length is not 64: " + str(headerLength)) + fp.seek(4, 1) + + # meta1Size = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + # numMeta1Entries = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + + meta1Offset = int.from_bytes(fp.read(4), "little") + fp.seek(16, 1) + numMeta2Entries = int.from_bytes(fp.read(4), "little") + meta2Offset = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + + # dataLength = int.from_bytes(fp.read(4), "little") + fp.seek(4, 1) + + dataOffset = int.from_bytes(fp.read(4), "little") + + # read Meta1 Block + fp.seek(meta1Offset, 0) + meta1DataLength = meta2Offset - meta1Offset + if meta1DataLength % 16 != 0: + raise ValueError( + "Meta1 has wrong number of bytes: " + str(meta1DataLength) + ) + + # read Meta2 Block + fp.seek(meta2Offset, 0) + meta2DataLength = dataOffset - meta2Offset + if meta2DataLength % 12 != 0: + raise ValueError( + "Meta2 has wrong number of bytes: " + str(meta2DataLength) + ) + meta2List: list[tuple[int, int, int]] = [] + for _ in range(numMeta2Entries): + entryHash = int.from_bytes(fp.read(4), "little") + offset = int.from_bytes(fp.read(4), "little") + fieldInfo = int.from_bytes(fp.read(4), "little") + meta2List.append((entryHash, offset, fieldInfo)) + + # read Data + fp.seek(dataOffset, 0) + for x in range(numMeta2Entries): + meta2Entry = meta2List[x] + fp.seek(dataOffset + meta2Entry[1], 0) + nameLength = (meta2Entry[2] & 0b11111111100) >> 2 + # null terminated string + nameBytes = fp.read(nameLength - 1) + fp.seek(1, 1) + name = bytes.decode(nameBytes, "utf-8") + if name != "estatename": + continue + valueLength = int.from_bytes(fp.read(4), "little") + valueBytes = fp.read(valueLength - 1) + value = bytes.decode(valueBytes, "utf-8") + self.name = value + break + + def getName(self) -> str: + if self.name == "": + return super().getName() + return self.name + + +class DarkestDungeonGame(BasicGame): + Name = "DarkestDungeon" + Author = "erri120" + Version = "0.2.0" + + GameName = "Darkest Dungeon" + GameShortName = "darkestdungeon" + GameNexusName = "darkestdungeon" + GameNexusId = 804 + GameSteamId = 262060 + GameGogId = 1719198803 + GameBinary = "_windows/win64/Darkest.exe" + GameDataPath = "" + GameDocumentsDirectory = "%DOCUMENTS%/Darkest" + GameSavesDirectory = "%GAME_DOCUMENTS%" + GameSupportURL = "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Darkest-Dungeon" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(DarkestDungeonModDataChecker()) + return True + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + + return [ + mobase.ExecutableInfo( + f"{game_name} Steam x64", + QFileInfo(game_dir, "_windows/win64/Darkest.exe"), + ).withWorkingDirectory(game_dir), + mobase.ExecutableInfo( + f"{game_name} x64", + QFileInfo(game_dir, "_windowsnosteam/win64/Darkest.exe"), + ).withWorkingDirectory(game_dir), + mobase.ExecutableInfo( + f"{game_name} Steam x32", + QFileInfo(game_dir, "_windows/win32/Darkest.exe"), + ).withWorkingDirectory(game_dir), + mobase.ExecutableInfo( + f"{game_name} x32", + QFileInfo(game_dir, "_windowsnosteam/win32/Darkest.exe"), + ).withWorkingDirectory(game_dir), + ] + + @staticmethod + def getCloudSaveDirectory() -> str | None: + steamPath = find_steam_path() + if steamPath is None: + return None + + userData = steamPath.joinpath("userdata") + for child in userData.iterdir(): + name = child.name + try: + userID = int(name) + except ValueError: + userID = -1 + if userID == -1: + continue + cloudSaves = child.joinpath("262060", "remote") + if cloudSaves.exists() and cloudSaves.is_dir(): + return str(cloudSaves) + return None + + def savesDirectory(self) -> QDir: + documentsSaves = self.documentsDirectory() + + if not self.is_steam(): + return documentsSaves + + cloudSaves = self.getCloudSaveDirectory() + if cloudSaves is None: + return documentsSaves + + return QDir(cloudSaves) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + profiles: list[Path] = [] + for path in Path(folder.absolutePath()).glob("profile_*"): + # profile_9 is only for the Multiplayer DLC "The Butcher's Circus" + # and contains different files than other profiles + if path.name == "profile_9": + continue + profiles.append(path) + + return [DarkestDungeonSaveGame(path) for path in profiles] diff --git a/libs/basic_games/games/game_darkmessiahofmightandmagic.py b/libs/basic_games/games/game_darkmessiahofmightandmagic.py new file mode 100644 index 0000000..ed3fbcd --- /dev/null +++ b/libs/basic_games/games/game_darkmessiahofmightandmagic.py @@ -0,0 +1,48 @@ +import struct +from pathlib import Path + +from PyQt6.QtGui import QImage + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DarkMessiahOfMightAndMagicGame(BasicGame): + Name = "Dark Messiah of Might and Magic Support Plugin" + Author = "Holt59" + Version = "0.1.0" + + GameName = "Dark Messiah of Might & Magic" + GameShortName = "darkmessiahofmightandmagic" + GameNexusName = "darkmessiahofmightandmagic" + GameNexusId = 628 + GameSteamId = 2100 + GameBinary = "mm.exe" + GameDataPath = "mm" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dark-Messiah-of-Might-&-Magic" + ) + + GameDocumentsDirectory = "%GAME_PATH%/mm" + GameSavesDirectory = "%GAME_PATH%/mm/SAVE" + GameSaveExtension = "sav" + + def _read_save_tga(self, filepath: Path) -> QImage | None: + # Qt TGA reader does not work for TGA, I hope that all files + # have the same format: + with open( + filepath.parent.joinpath(filepath.name.replace(".sav", ".tga")), "rb" + ) as fp: + data = fp.read() + _, _, w, h, bpp, _ = struct.unpack("<HHHHBB", data[8:18]) + if bpp != 24: + return None + return QImage(data[18:], w, h, QImage.Format.Format_RGB888) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(BasicGameSaveGameInfo(self._read_save_tga)) + return True diff --git a/libs/basic_games/games/game_darksouls.py b/libs/basic_games/games/game_darksouls.py new file mode 100644 index 0000000..722d675 --- /dev/null +++ b/libs/basic_games/games/game_darksouls.py @@ -0,0 +1,22 @@ +from ..basic_game import BasicGame + + +class DarkSoulsGame(BasicGame): + Name = "DarkSouls" + Author = "Holt59" + Version = "0.1.0" + + GameName = "Dark Souls" + GameShortName = "darksouls" + GameNexusName = "darksouls" + GameNexusId = 162 + GameSteamId = 211420 + GameBinary = "DATA/DARKSOULS.exe" + GameDataPath = "DATA" + GameDocumentsDirectory = "%DOCUMENTS%/NBGI/DarkSouls" + GameSavesDirectory = "%DOCUMENTS%/NBGI/DarkSouls" + GameSaveExtension = "sl2" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dark-Souls" + ) diff --git a/libs/basic_games/games/game_darksouls2sotfs.py b/libs/basic_games/games/game_darksouls2sotfs.py new file mode 100644 index 0000000..3f69d2a --- /dev/null +++ b/libs/basic_games/games/game_darksouls2sotfs.py @@ -0,0 +1,22 @@ +from ..basic_game import BasicGame + + +class DarkSouls2SotfsGame(BasicGame): + Name = "DarkSouls2Sotfs" + Author = "raehik" + Version = "0.1.0" + + GameName = "Dark Souls II: Scholar of the First Sin" + GameShortName = "darksouls2sotfs" + GameNexusName = "darksouls2" + GameNexusId = 482 + GameSteamId = 335300 + GameBinary = "Game/DarkSoulsII.exe" + GameDataPath = "Game" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Roaming/DarkSoulsII" + GameSavesDirectory = "%USERPROFILE%/AppData/Roaming/DarkSoulsII" + GameSaveExtension = "sl2" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dark-Souls-2-Sotfs" + ) diff --git a/libs/basic_games/games/game_dispatch.py b/libs/basic_games/games/game_dispatch.py new file mode 100644 index 0000000..3b22084 --- /dev/null +++ b/libs/basic_games/games/game_dispatch.py @@ -0,0 +1,102 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame +from ..steam_utils import find_steam_path + + +class DispatchModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + move={ + "*.fliarchive": "Mods/", + "*.pak": "Paks/~mods/", + "*.ucas": "Paks/~mods/", + "*.utoc": "Paks/~mods/", + }, + valid=["L10N", "sound", "Mods", "Paks"], + ) + ) + + +class Dispatch(BasicGame, mobase.IPluginFileMapper): + Name = "Dispatch Support Plugin" + Author = "Syer10" + Version = "0.1.0" + + GameName = "Dispatch" + GameShortName = "dispatch" + GameNexusName = "dispatch" + GameValidShortNames = [] + + GameDataPath = "Dispatch/Content/" + GameBinary = "Dispatch/Binaries/Win64/Dispatch-Win64-Shipping.exe" + GameSteamId = 2592160 + + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dispatch" + ) + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(DispatchModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def executables(self): + return [ + mobase.ExecutableInfo("Dispatch", self.GameBinary), + ] + + ## SAVE + # credit to game.darkestdungeon.py + @staticmethod + def getCloudSaveDirectory() -> str | None: + steamPath = find_steam_path() + if steamPath is None: + return None + + userData = steamPath.joinpath("userdata") + for child in userData.iterdir(): + name = child.name + try: + int(name) + except ValueError: + continue + + cloudSaves = child.joinpath("2592160/remote") + if cloudSaves.exists() and cloudSaves.is_dir(): + return str(cloudSaves) + return None + + def savesDirectory(self) -> QDir: + return QDir(self.getCloudSaveDirectory()) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + saves: list[Path] = [] + for path in Path(folder.absolutePath()).glob("*.bin"): + saves.append(path) + + ##TODO: need a proper implementation + return [BasicGameSaveGame(path) for path in saves] + + ## MAPPING + + def exeDirectory(self) -> QDir: + return QDir(QDir(self.gameDirectory()).filePath("Dispatch/Binaries/Win64")) + + def mappings(self) -> list[mobase.Mapping]: + return [ + mobase.Mapping("*.dll", self.exeDirectory().absolutePath(), False, True), + ] diff --git a/libs/basic_games/games/game_divinityoriginalsin.py b/libs/basic_games/games/game_divinityoriginalsin.py new file mode 100644 index 0000000..435846e --- /dev/null +++ b/libs/basic_games/games/game_divinityoriginalsin.py @@ -0,0 +1,39 @@ +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DivinityOriginalSinGame(BasicGame): + Name = "Divinity: Original Sin (Classic) Support Plugin" + Author = "LostDragonist" + Version = "1.0.0" + + GameName = "Divinity: Original Sin (Classic)" + GameShortName = "divinityoriginalsin" + GameNexusName = "divinityoriginalsin" + GameValidShortNames = ["divinityoriginalsin"] + GameNexusId = 573 + GameSteamId = [230230] + GameBinary = "Shipping/EoCApp.exe" + GameDataPath = "Data" + GameSaveExtension = "lsv" # Not confirmed + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin" + ) + GameSavesDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin/PlayerProfiles" + ) + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Divinity:-Original-Sin" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo( + lambda s: s.with_suffix(".png") # Not confirmed + ) + ) + return True diff --git a/libs/basic_games/games/game_divinityoriginalsinee.py b/libs/basic_games/games/game_divinityoriginalsinee.py new file mode 100644 index 0000000..fd3836f --- /dev/null +++ b/libs/basic_games/games/game_divinityoriginalsinee.py @@ -0,0 +1,119 @@ +# -*- encoding: utf-8 -*- + +import os +from pathlib import Path + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame + + +class DivinityOriginalSinEnhancedEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + folders: list[mobase.IFileTree] = [] + files: list[mobase.FileTreeEntry] = [] + + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + folders.append(entry) + else: + files.append(entry) + + VALID_FOLDERS = [ + "Cursors", + "DLC", + "Engine", + "Fonts", + "Localization", + "PakInfo", + "PlayerProfiles", + "Public", + "Shaders", + DivinityOriginalSinEnhancedEditionGame.DOCS_MOD_SPECIAL_NAME, + ] + for src_folder in folders: + for dst_folder in VALID_FOLDERS: + if src_folder.name().lower() == dst_folder.lower(): + return mobase.ModDataChecker.VALID + + VALID_FILE_EXTENSIONS = [ + ".pak", + ] + for src_file in files: + for extension in VALID_FILE_EXTENSIONS: + if src_file.name().lower().endswith(extension.lower()): + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class DivinityOriginalSinEnhancedEditionGame(BasicGame, mobase.IPluginFileMapper): + Name = "Divinity: Original Sin (Enhanced Edition) Support Plugin" + Author = "LostDragonist" + Version = "1.0.0" + + GameName = "Divinity: Original Sin (Enhanced Edition)" + GameShortName = "divinityoriginalsinenhancededition" + GameNexusName = "divinityoriginalsinenhancededition" + GameValidShortNames = ["divinityoriginalsin"] + GameNexusId = 1995 + GameSteamId = [373420] + GameGogId = [1445516929, 1445524575] + GameBinary = "Shipping/EoCApp.exe" + GameDataPath = "Data" + GameSaveExtension = "lsv" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/Divinity Original Sin Enhanced Edition" + ) + GameSavesDirectory = ( + "%USERPROFILE%/Documents/Larian Studios/" + "Divinity Original Sin Enhanced Edition/PlayerProfiles" + ) + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Divinity:-Original-Sin" + ) + + DOCS_MOD_SPECIAL_NAME = "DOCS_MOD" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".png"))) + self._register_feature(DivinityOriginalSinEnhancedEditionModDataChecker()) + return True + + def mappings(self) -> list[mobase.Mapping]: + map: list[mobase.Mapping] = [] + modDirs = self._listDirsRecursive(Path(self.DOCS_MOD_SPECIAL_NAME)) + for dir_ in modDirs: + for file_ in self._organizer.findFiles(path=dir_, filter=lambda x: True): + m = mobase.Mapping() + m.createTarget = True + m.isDirectory = False + m.source = file_ + m.destination = os.path.join( + self.documentsDirectory().absoluteFilePath("Mods"), + file_.split(self.DOCS_MOD_SPECIAL_NAME)[1].strip("\\").strip("/"), + ) + map.append(m) + return map + + def primarySources(self): + return self.GameValidShortNames + + def _listDirsRecursive(self, prefix: Path) -> list[str]: + res = [str(prefix)] + dirs = self._organizer.listDirectories(str(prefix)) + for dir_ in dirs: + res.extend(self._listDirsRecursive(prefix.joinpath(dir_))) + return res diff --git a/libs/basic_games/games/game_dragonsdogmadarkarisen.py b/libs/basic_games/games/game_dragonsdogmadarkarisen.py new file mode 100644 index 0000000..e26b00e --- /dev/null +++ b/libs/basic_games/games/game_dragonsdogmadarkarisen.py @@ -0,0 +1,19 @@ +from ..basic_game import BasicGame + + +class NoMansSkyGame(BasicGame): + Name = "Dragon's Dogma: Dark Arisen Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "Dragon's Dogma: Dark Arisen" + GameShortName = "dragonsdogma" + GaneNexusHame = "dragonsdogma" + GameSteamId = 367500 + GameGogId = 1242384383 + GameBinary = "DDDA.exe" + GameDataPath = "nativePC" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dragon's-Dogma:-Dark-Arisen" + ) diff --git a/libs/basic_games/games/game_dungeonsiege1.py b/libs/basic_games/games/game_dungeonsiege1.py new file mode 100644 index 0000000..4eb983d --- /dev/null +++ b/libs/basic_games/games/game_dungeonsiege1.py @@ -0,0 +1,92 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class DungeonSiegeIModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def get_resources_and_maps( + self, tree: mobase.IFileTree + ) -> tuple[list[mobase.FileTreeEntry], list[mobase.FileTreeEntry]]: + ress: list[mobase.FileTreeEntry] = [] + maps: list[mobase.FileTreeEntry] = [] + + for e in tree: + if e.isFile(): + if e.suffix().lower() == "dsres": + ress.append(e) + elif e.suffix().lower() == "dsmap": + maps.append(e) + + return ress, maps + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check if we have a Resources / Maps folder or .dsres/.dsmap + ress, maps = self.get_resources_and_maps(filetree) + + if not ress and not maps: + if filetree.exists("Resources") or filetree.exists("Maps"): + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.INVALID + + return mobase.ModDataChecker.FIXABLE + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + ress, maps = self.get_resources_and_maps(filetree) + + if ress: + rfolder = filetree.addDirectory("Resources") + for r in ress: + rfolder.insert(r, mobase.IFileTree.REPLACE) + if maps: + rfolder = filetree.addDirectory("Maps") + for r in maps: + rfolder.insert(r, mobase.IFileTree.REPLACE) + return filetree + + +class DungeonSiegeIGame(BasicGame): + Name = "Dungeon Siege I" + Author = "mrudat" + Version = "0.0.1" + + GameName = "Dungeon Siege I" + GameShortName = "dungeonsiege1" + GameNexusName = "dungeonsiege1" + GameNexusId = 541 + GameSteamId = [39190] + GameGogId = [1142020247] + GameBinary = "DungeonSiege.exe" + GameDataPath = "" + GameSavesDirectory = "%GAME_DOCUMENTS%/Save" + GameDocumentsDirectory = "%DOCUMENTS%/Dungeon Siege" + GameSaveExtension = "dssave" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dungeon-Siege-I" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(DungeonSiegeIModDataChecker()) + return True + + def executables(self): + execs = super().executables() + execs.append( + mobase.ExecutableInfo( + "Dungeon Siege Video Configuration", + QFileInfo(self.gameDirectory().absoluteFilePath("DSVideoConfig.exe")), + ) + ) + return execs + + def iniFiles(self): + return ["DungeonSiege.ini"] diff --git a/libs/basic_games/games/game_dungeonsiege2.py b/libs/basic_games/games/game_dungeonsiege2.py new file mode 100644 index 0000000..4a8f1de --- /dev/null +++ b/libs/basic_games/games/game_dungeonsiege2.py @@ -0,0 +1,92 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class DungeonSiegeIIModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def get_resources_and_maps( + self, tree: mobase.IFileTree + ) -> tuple[list[mobase.FileTreeEntry], list[mobase.FileTreeEntry]]: + ress: list[mobase.FileTreeEntry] = [] + maps: list[mobase.FileTreeEntry] = [] + + for e in tree: + if e.isFile(): + if e.suffix().lower() == "ds2res": + ress.append(e) + elif e.suffix().lower() == "ds2map": + maps.append(e) + + return ress, maps + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check if we have a Resources / Maps folder or .ds2res/.ds2map + ress, maps = self.get_resources_and_maps(filetree) + + if not ress and not maps: + if filetree.exists("Resources") or filetree.exists("Maps"): + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.INVALID + + return mobase.ModDataChecker.FIXABLE + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + ress, maps = self.get_resources_and_maps(filetree) + + if ress: + rfolder = filetree.addDirectory("Resources") + for r in ress: + rfolder.insert(r, mobase.IFileTree.REPLACE) + if maps: + rfolder = filetree.addDirectory("Maps") + for r in maps: + rfolder.insert(r, mobase.IFileTree.REPLACE) + return filetree + + +class DungeonSiegeIIGame(BasicGame): + Name = "Dungeon Siege II" + Author = "Holt59" + Version = "0.1.1" + + GameName = "Dungeon Siege II" + GameShortName = "dungeonsiegeii" + GameNexusName = "dungeonsiegeii" + GameNexusId = 2078 + GameSteamId = [39200] + GameGogId = [1142020247] + GameBinary = "DungeonSiege2.exe" + GameDataPath = "" + GameSavesDirectory = "%GAME_DOCUMENTS%/Save" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/Dungeon Siege 2" + GameSaveExtension = "ds2party" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Dungeon-Siege-II" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(DungeonSiegeIIModDataChecker()) + return True + + def executables(self): + execs = super().executables() + execs.append( + mobase.ExecutableInfo( + "Dungeon Siege Video Configuration", + QFileInfo(self.gameDirectory().absoluteFilePath("DS2VideoConfig.exe")), + ) + ) + return execs + + def iniFiles(self): + return ["DungeonSiege2.ini"] diff --git a/libs/basic_games/games/game_f123.py b/libs/basic_games/games/game_f123.py new file mode 100644 index 0000000..e740a40 --- /dev/null +++ b/libs/basic_games/games/game_f123.py @@ -0,0 +1,13 @@ +from ..basic_game import BasicGame + + +class F123Game(BasicGame): + Name = "F1 23 Support Plugin" + Author = "ju5tA1ex" + Version = "1.0.0" + + GameName = "F1 23" + GameShortName = "F1 23" + GameSteamId = 2108330 + GameBinary = "F1_23.exe" + GameDataPath = "" diff --git a/libs/basic_games/games/game_fantasylifei.py b/libs/basic_games/games/game_fantasylifei.py new file mode 100644 index 0000000..ae3cab1 --- /dev/null +++ b/libs/basic_games/games/game_fantasylifei.py @@ -0,0 +1,102 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame +from ..steam_utils import find_steam_path + + +class FantasyLifeIModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + move={ + "*.fliarchive": "Mods/", + "*.pak": "Paks/~mods/", + "*.ucas": "Paks/~mods/", + "*.utoc": "Paks/~mods/", + }, + valid=["L10N", "sound", "Mods", "Paks"], + ) + ) + + +class FantasyLifeI(BasicGame, mobase.IPluginFileMapper): + Name = "Fantasy Life I Support Plugin" + Author = "AmeliaCute" + Version = "0.2.2" + + GameName = "FANTASY LIFE i" + GameShortName = "fantasylifei" + GameNexusName = "fantasylifeithegirlwhostealstime" + GameValidShortNames = ["fli"] + + GameDataPath = "Game/Content/" + GameBinary = "Game/Binaries/Win64/NFL1-Win64-Shipping.exe" + GameSteamId = 2993780 + + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Fantasy-Life-I:-The-Girl-Who-Steals-Time" + ) + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(FantasyLifeIModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def executables(self): + return [ + mobase.ExecutableInfo("Fantasy Life I", self.GameBinary), + ] + + ## SAVE + # credit to game.darkestdungeon.py + @staticmethod + def getCloudSaveDirectory() -> str | None: + steamPath = find_steam_path() + if steamPath is None: + return None + + userData = steamPath.joinpath("userdata") + for child in userData.iterdir(): + name = child.name + try: + int(name) + except ValueError: + continue + + cloudSaves = child.joinpath("2993780/remote") + if cloudSaves.exists() and cloudSaves.is_dir(): + return str(cloudSaves) + return None + + def savesDirectory(self) -> QDir: + return QDir(self.getCloudSaveDirectory()) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + saves: list[Path] = [] + for path in Path(folder.absolutePath()).glob("*.bin"): + saves.append(path) + + ##TODO: need a proper implementation + return [BasicGameSaveGame(path) for path in saves] + + ## MAPPING + + def exeDirectory(self) -> QDir: + return QDir(QDir(self.gameDirectory()).filePath("Game/Binaries/Win64")) + + def mappings(self) -> list[mobase.Mapping]: + return [ + mobase.Mapping("*.dll", self.exeDirectory().absolutePath(), False, True), + ] diff --git a/libs/basic_games/games/game_finalfantasy7rebirth.py b/libs/basic_games/games/game_finalfantasy7rebirth.py new file mode 100644 index 0000000..c592824 --- /dev/null +++ b/libs/basic_games/games/game_finalfantasy7rebirth.py @@ -0,0 +1,157 @@ +import math +import random +from pathlib import Path +from typing import Iterable, List + +import mobase + +from ..basic_features.basic_mod_data_checker import BasicModDataChecker, GlobPatterns +from ..basic_game import BasicGame + + +class FinalFantasy7RebirthDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + move={ + "*.pak": "End/Content/Paks/~Mods/", + "*.ucas": "End/Content/Paks/~Mods/", + "*.utoc": "End/Content/Paks/~Mods/", + "*.dll": "End/Binaries/Win64/", + }, + valid=[ + "End/Content/Paks/~Mods", + "End/Binaries/Win64", + "End", + "/", + "_ROOT", + "_ROOT/End/Binaries/Win64", + "_ROOT/End/Content/Paks/~Mods", + ], + ) + ) + + +class FinalFantasy7RebirthFileManager: + _mods_extensions = [".pak", ".ucas", ".utoc"] + _file_extensions = [".dll"] + + def __init__( + self, + mod_path: Path, + file_path: Path, + organizer: mobase.IOrganizer, + load_enabled: mobase.MoVariant, + ) -> None: + self._mod_path = mod_path + self._file_path = file_path + self._organizer = organizer + self._load_enabled = load_enabled + + def _active_mods(self) -> Iterable[Path]: + mods_path = Path(self._organizer.modsPath()) + mod_list = self._organizer.modList() + mod_load_order = mod_list.allModsByProfilePriority() + for mod in mod_load_order: + if mod_list.state(mod) & mobase.ModState.ACTIVE: + yield mods_path / mod + + def _recursive(self, child: Path, mod_prefix: str) -> Iterable[mobase.Mapping]: + if child.suffix.lower() in self._file_extensions: + dest_mod_path = self._file_path / child.name + yield mobase.Mapping(str(child), str(dest_mod_path), child.is_dir(), True) + + if child.suffix.lower() in self._mods_extensions: + child_name = ( + child.with_stem(mod_prefix + child.stem).name + if self._load_enabled + else child.name + ) + dest_mod_path = self._mod_path / child_name + yield mobase.Mapping(str(child), str(dest_mod_path), child.is_dir(), True) + + if child.is_dir(): + for inner in child.iterdir(): + yield from self._recursive(inner, mod_prefix) + + def mod_mapping(self) -> Iterable[mobase.Mapping]: + priority_digits = math.floor(math.log10(random.randrange(1, 999))) + 1 + for priority, mod_path in enumerate(self._active_mods()): + mod_prefix = str(priority).zfill(priority_digits) + "_" + for child in mod_path.iterdir(): + yield from self._recursive(child, mod_prefix) + + +class FinalFantasy7RebirthGame(BasicGame, mobase.IPluginFileMapper): + Name = "Final Fasntasy 7 Rebirth Support Plugin" + Author = "diegofesanto, TheUnlocked" + Version = "0.0.1" + + GameName = "Final Fantasy VII Rebirth" + GameShortName = "finalfantasy7rebirth" + GameNexuName = "finalfantasy7rebirth" + GameSteamId = 2909400 + GameBinary = "End/Binaries/Win64/ff7rebirth_.exe" + GameDataPath = "_ROOT" + GameSaveExtension = "sav" + + _forced_libraries = ["xinput1_3.dll"] + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + + self._register_feature(FinalFantasy7RebirthDataChecker()) + + return True + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "enforce_mod_load_order", + ("Force mod load order by list priority"), + True, + ) + ] + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + bin_path = game_dir.absoluteFilePath(self.binaryName()) + return [ + # Default + mobase.ExecutableInfo( + f"{game_name}", + bin_path, + ) + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + exe = Path(self.binaryName()).name + return [ + mobase.ExecutableForcedLoadSetting(exe, lib).withEnabled(True) + for lib in self._forced_libraries + ] + + def _get_settings(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self.name(), key) + + def mappings(self) -> List[mobase.Mapping]: + mod_path = Path(self.gameDirectory().absolutePath()) / "End/Content/Paks/~Mods" + file_path = Path(self.gameDirectory().absolutePath()) / "End/Binaries/Win64" + load_enabled = self._get_settings("enforce_mod_load_order") + file_manager = FinalFantasy7RebirthFileManager( + mod_path, file_path, self._organizer, load_enabled + ) + + return [ + mobase.Mapping( + self._organizer.overwritePath(), + str(mod_path), + True, + True, + ) + ] + list(file_manager.mod_mapping()) diff --git a/libs/basic_games/games/game_finalfantasy7remake.py b/libs/basic_games/games/game_finalfantasy7remake.py new file mode 100644 index 0000000..44dd74f --- /dev/null +++ b/libs/basic_games/games/game_finalfantasy7remake.py @@ -0,0 +1,78 @@ +import math +from pathlib import Path +from typing import Iterable, List + +import mobase + +from ..basic_game import BasicGame + + +class FinalFantasy7RemakeGame(BasicGame, mobase.IPluginFileMapper): + Name = "Final Fantasy VII Remake Support Plugin" + Author = "TheUnlocked" + Version = "1.0.0" + + GameName = "Final Fantasy VII Remake" + GameShortName = "finalfantasy7remake" + GameNexusName = "finalfantasy7remake" + GameSteamId = 1462040 + GameBinary = "ff7remake.exe" + GameSaveExtension = "sav" + # _ROOT is a placeholder value. + # In order to properly apply load order to mods, custom mapping is used below. + GameDataPath = "_ROOT" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + return BasicGame.init(self, organizer) + + def _get_mods_path(self): + """The directory path of where .pak files from mods should go""" + return ( + Path(self.gameDirectory().absolutePath()) + / "End" + / "Content" + / "Paks" + / "~mods" + ) + + def _active_mod_paths(self) -> Iterable[Path]: + mods_parent_path = Path(self._organizer.modsPath()) + modlist = self._organizer.modList().allModsByProfilePriority() + for mod in modlist: + if self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: + yield mods_parent_path / mod + + def _active_mod_mappings(self, mod_paths: List[Path]) -> Iterable[mobase.Mapping]: + if not mod_paths: + return + pak_priority_digits = math.floor(math.log10(len(mod_paths))) + 1 + + for priority, mod_path in enumerate(mod_paths): + pak_prefix = str(priority).zfill(pak_priority_digits) + "_" + for child in mod_path.iterdir(): + dest_path = ( + self._get_mods_path() + / child.with_stem(pak_prefix + child.stem).name + ) + if child.is_dir() or child.suffix.lower() == ".pak": + yield mobase.Mapping( + str(child), + str(dest_path), + child.is_dir(), + ) + + def mappings(self) -> List[mobase.Mapping]: + return [ + # Not applying load order modifications to overwrites is OK + # since the UX is better this way and it will generally work out anyways + mobase.Mapping( + self._organizer.overwritePath(), + str(self._get_mods_path()), + True, + True, + ) + ] + list(self._active_mod_mappings(list(self._active_mod_paths()))) diff --git a/libs/basic_games/games/game_gta-3-de.py b/libs/basic_games/games/game_gta-3-de.py new file mode 100644 index 0000000..c9fc338 --- /dev/null +++ b/libs/basic_games/games/game_gta-3-de.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class GTA3DefinitiveEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if Path(entry.name().casefold()).suffix == ".pak": + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class GTA3DefinitiveEditionGame(BasicGame): + Name = "Grand Theft Auto III - Definitive Edition Support Plugin" + Author = "dekart811" + Version = "1.0" + + GameName = "GTA III - Definitive Edition" + GameShortName = "grandtheftautothetrilogy" + GameNexusName = "grandtheftautothetrilogy" + GameBinary = "Gameface/Binaries/Win64/LibertyCity.exe" + GameDataPath = "Gameface/Content/Paks/~mods" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Rockstar Games/" + "GTA III Definitive Edition/Config/WindowsNoEditor" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/../../SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Grand-Theft-Auto:-The-Trilogy-%E2%80%90-The-Definitive-Edition" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(GTA3DefinitiveEditionModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "GTA III - Definitive Edition", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "GTA III - Definitive Edition (DirectX 12)", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-dx12"), + ] + + def iniFiles(self): + return ["GameUserSettings.ini", "CustomSettings.ini"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_gta-san-andreas-de.py b/libs/basic_games/games/game_gta-san-andreas-de.py new file mode 100644 index 0000000..200a562 --- /dev/null +++ b/libs/basic_games/games/game_gta-san-andreas-de.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class GTASanAndreasDefinitiveEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if Path(entry.name().casefold()).suffix == ".pak": + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class GTASanAndreasDefinitiveEditionGame(BasicGame): + Name = "Grand Theft Auto: San Andreas - Definitive Edition Support Plugin" + Author = "dekart811" + Version = "1.0" + + GameName = "GTA: San Andreas - Definitive Edition" + GameShortName = "grandtheftautothetrilogy" + GameNexusName = "grandtheftautothetrilogy" + GameBinary = "Gameface/Binaries/Win64/SanAndreas.exe" + GameDataPath = "Gameface/Content/Paks/~mods" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Rockstar Games/" + "GTA San Andreas Definitive Edition/Config/WindowsNoEditor" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/../../SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Grand-Theft-Auto:-The-Trilogy-%E2%80%90-The-Definitive-Edition" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(GTASanAndreasDefinitiveEditionModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "GTA: San Andreas - Definitive Edition", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "GTA: San Andreas - Definitive Edition (DirectX 12)", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-dx12"), + ] + + def iniFiles(self): + return ["GameUserSettings.ini", "CustomSettings.ini"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_gta-vice-city-de.py b/libs/basic_games/games/game_gta-vice-city-de.py new file mode 100644 index 0000000..fa068e6 --- /dev/null +++ b/libs/basic_games/games/game_gta-vice-city-de.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class GTAViceCitysDefinitiveEditionModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if Path(entry.name().casefold()).suffix == ".pak": + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class GTAViceCityDefinitiveEditionGame(BasicGame): + Name = "Grand Theft Auto: Vice City - Definitive Edition Support Plugin" + Author = "dekart811" + Version = "1.0" + + GameName = "GTA: Vice City - Definitive Edition" + GameShortName = "grandtheftautothetrilogy" + GameNexusName = "grandtheftautothetrilogy" + GameBinary = "Gameface/Binaries/Win64/ViceCity.exe" + GameDataPath = "Gameface/Content/Paks/~mods" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/Rockstar Games/" + "GTA Vice City Definitive Edition/Config/WindowsNoEditor" + ) + GameSavesDirectory = "%GAME_DOCUMENTS%/../../SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Grand-Theft-Auto:-The-Trilogy-%E2%80%90-The-Definitive-Edition" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(GTAViceCitysDefinitiveEditionModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "GTA: Vice City - Definitive Edition", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "GTA: Vice City - Definitive Edition (DirectX 12)", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-dx12"), + ] + + def iniFiles(self): + return ["GameUserSettings.ini", "CustomSettings.ini"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_kerbalspaceprogram.py b/libs/basic_games/games/game_kerbalspaceprogram.py new file mode 100644 index 0000000..fec834f --- /dev/null +++ b/libs/basic_games/games/game_kerbalspaceprogram.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class KerbalSpaceProgramSaveGame(BasicGameSaveGame): + def allFiles(self): + files = [super().getFilepath()] + banner = self._filepath.joinpath("banners").joinpath(f"${self.getName()}.png") + if banner.exists(): + files.append(banner.as_posix()) + return files + + def getName(self): + return self._filepath.stem + + def getSaveGroupIdentifier(self): + return self._filepath.parent.name + + +class KerbalSpaceProgramGame(BasicGame): + Name = "Kerbal Space Program Support Plugin" + Author = "LaughingHyena" + Version = "1.0.0" + + GameName = "Kerbal Space Program" + GameShortName = "kerbalspaceprogram" + GameNexusName = "kerbalspaceprogram" + GameSteamId = [220200, 283740, 982970] + GameBinary = "KSP_x64.exe" + GameDataPath = "GameData" + GameSavesDirectory = "%GAME_PATH%/saves" + GameSaveExtension = "sfs" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Kerbal-Space-Program" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo( + lambda s: str( + Path(s).parent.joinpath("banners").joinpath(f"{Path(s).stem}.png") + ) + ) + ) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + KerbalSpaceProgramSaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*/*.{ext}") + ] diff --git a/libs/basic_games/games/game_kingdomcomedeliverance.py b/libs/basic_games/games/game_kingdomcomedeliverance.py new file mode 100644 index 0000000..0bc5fe1 --- /dev/null +++ b/libs/basic_games/games/game_kingdomcomedeliverance.py @@ -0,0 +1,48 @@ +import os + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_game import BasicGame + + +class KingdomComeDeliveranceGame(BasicGame): + Name = "Kingdom Come Deliverance Support Plugin" + Author = "Silencer711" + Version = "1.0.0" + + GameName = "Kingdom Come: Deliverance" + GameShortName = "kingdomcomedeliverance" + GameNexusName = "kingdomcomedeliverance" + GameNexusId = 2298 + GameSteamId = [379430] + GameGogId = [1719198803] + GameEpicId = "Eel" + GameBinary = "bin/Win64/KingdomCome.exe" + GameDataPath = "mods" + GameSaveExtension = "whs" + GameDocumentsDirectory = "%GAME_PATH%" + GameSavesDirectory = "%USERPROFILE%/Saved Games/kingdomcome/saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Kingdom-Come:-Deliverance" + ) + + def iniFiles(self): + return ["custom.cfg", "system.cfg", "user.cfg"] + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create .cfg files if they don't exist + for iniFile in self.iniFiles(): + iniPath = self.documentsDirectory().absoluteFilePath(iniFile) + if not os.path.exists(iniPath): + with open(iniPath, "w") as _: + pass + + # Create the mods directory if it doesn't exist + modsPath = self.dataDirectory().absolutePath() + if not os.path.exists(modsPath): + os.mkdir(modsPath) + + super().initializeProfile(directory, settings) diff --git a/libs/basic_games/games/game_masterduel.py b/libs/basic_games/games/game_masterduel.py new file mode 100644 index 0000000..8c23512 --- /dev/null +++ b/libs/basic_games/games/game_masterduel.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import List, Optional + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class MasterDuelGame(BasicGame, mobase.IPluginFileMapper): + Name = "Yu-Gi-Oh! Master Duel Support Plugin" + Author = "The Conceptionist & uwx" + Version = "1.0.2" + Description = ( + "Adds support for basic Yu-Gi-Oh! Master Duel mods.\n" + 'Eligible folders for mods are "0000" and "AssetBundle".' + ) + + GameName = "Yu-Gi-Oh! Master Duel" + GameShortName = "masterduel" + GameNexusName = "yugiohmasterduel" + GameNexusId = 4272 + GameSteamId = 1449850 + GameBinary = "masterduel.exe" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def executables(self): + return [ + mobase.ExecutableInfo( + "Yu-Gi-Oh! Master Duel", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-popupwindow"), + ] + + # dataDirectory returns the specific LocalData folder because + # this is where most mods will go to + def dataDirectory(self) -> QDir: + return QDir(self.userDataDir()) + + _userDataDirCached: Optional[str] = None + + # Gets the LocalData/xxxxxxxxx directory. This directory has a different, + # unique, 8-character hex name for each user. + def userDataDir(self) -> str: + if self._userDataDirCached is not None: + return self._userDataDirCached + + dir = self.gameDirectory() + dir.cd("LocalData") + + subdirs = dir.entryList(filters=QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot) + dir.cd(subdirs[0]) + + self._userDataDirCached = dir.absolutePath() + return self._userDataDirCached + + def mappings(self) -> List[mobase.Mapping]: + modsPath = Path(self._organizer.modsPath()) + unityMods = self.getUnityDataMods() + + mappings: List[mobase.Mapping] = [] + + for modName in unityMods: + m = mobase.Mapping() + m.createTarget = False + m.isDirectory = True + m.source = modsPath.joinpath(modName, "AssetBundle").as_posix() + m.destination = self.gameDirectory().filePath( + Path("masterduel_Data", "StreamingAssets", "AssetBundle").as_posix() + ) + mappings.append(m) + + return mappings + + def getUnityDataMods(self) -> list[str]: + modsPath = Path(self._organizer.modsPath()) + allMods = self._organizer.modList().allModsByProfilePriority() + + unityMods: list[str] = [] + for modName in allMods: + if self._organizer.modList().state(modName) & mobase.ModState.ACTIVE != 0: + if modsPath.joinpath(modName, "AssetBundle").exists(): + unityMods.append(modName) + + return unityMods diff --git a/libs/basic_games/games/game_metalgearsolid2mc.py b/libs/basic_games/games/game_metalgearsolid2mc.py new file mode 100644 index 0000000..30804b8 --- /dev/null +++ b/libs/basic_games/games/game_metalgearsolid2mc.py @@ -0,0 +1,17 @@ +from ..basic_game import BasicGame + + +class MetalGearSolid2MCGame(BasicGame): + Name = ( + "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version Support Plugin" + ) + Author = "AkiraJkr" + Version = "1.0.0" + + GameName = "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version" + GameShortName = "metalgearsolid2mc" + GameNexusName = "metalgearsolid2mc" + GameSteamId = 2131640 + GameBinary = "METAL GEAR SOLID2.exe" + GameDataPath = "" + GameLauncher = "launcher.exe" diff --git a/libs/basic_games/games/game_metalgearsolid3mc.py b/libs/basic_games/games/game_metalgearsolid3mc.py new file mode 100644 index 0000000..10f669c --- /dev/null +++ b/libs/basic_games/games/game_metalgearsolid3mc.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class MetalGearSolid3MCGame(BasicGame): + Name = "METAL GEAR SOLID 3: Snake Eater - Master Collection Version Support Plugin" + Author = "AkiraJkr" + Version = "1.0.0" + + GameName = "METAL GEAR SOLID 3: Snake Eater - Master Collection Version" + GameShortName = "metalgearsolid3mc" + GameNexusName = "metalgearsolid3mc" + GameSteamId = 2131650 + GameBinary = "METAL GEAR SOLID3.exe" + GameDataPath = "" + GameLauncher = "launcher.exe" diff --git a/libs/basic_games/games/game_mirrorsedge.py b/libs/basic_games/games/game_mirrorsedge.py new file mode 100644 index 0000000..db81908 --- /dev/null +++ b/libs/basic_games/games/game_mirrorsedge.py @@ -0,0 +1,19 @@ +from ..basic_game import BasicGame + + +class MirrorsEdgeGame(BasicGame): + Name = "Mirror's Edge Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "Mirror's Edge" + GameShortName = "mirrorsedge" + GaneNexusHame = "" + GameSteamId = 17410 + GameGogId = 1893001152 + GameBinary = "Binaries/MirrorsEdge.exe" + GameDataPath = "TdGame" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Mirror's-Edge" + ) diff --git a/libs/basic_games/games/game_monsterhunterrise.py b/libs/basic_games/games/game_monsterhunterrise.py new file mode 100644 index 0000000..e63ed10 --- /dev/null +++ b/libs/basic_games/games/game_monsterhunterrise.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class MonsterHunterRiseGame(BasicGame): + Name = "Monster Hunter: Rise Support Plugin" + Author = "RodolfoFigueroa" + Version = "1.0.0" + + GameName = "Monster Hunter: Rise" + GameShortName = "monsterhunterrise" + GameBinary = "MonsterHunterRise.exe" + GameDataPath = "%GAME_PATH%" + GameSaveExtension = "bin" + GameNexusId = 4095 + GameSteamId = 1446780 diff --git a/libs/basic_games/games/game_monsterhunterworld.py b/libs/basic_games/games/game_monsterhunterworld.py new file mode 100644 index 0000000..290048c --- /dev/null +++ b/libs/basic_games/games/game_monsterhunterworld.py @@ -0,0 +1,21 @@ +from ..basic_game import BasicGame + + +class MonsterHunterWorldGame(BasicGame): + Name = "Monster Hunter: World Support Plugin" + Author = "prz" + Version = "1.0.0" + + GameName = "Monster Hunter: World" + GameShortName = "monsterhunterworld" + GameNexusName = "monsterhunterworld" + GameNexusId = 2531 + GameBinary = "MonsterHunterWorld.exe" + GameLauncher = "MonsterHunterWorld.exe" + GameDataPath = "%GAME_PATH%" + GameSaveExtension = "dat" + GameSteamId = 582010 + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Monster-Hunter:-World" + ) diff --git a/libs/basic_games/games/game_mountandblade2.py b/libs/basic_games/games/game_mountandblade2.py new file mode 100644 index 0000000..df2c157 --- /dev/null +++ b/libs/basic_games/games/game_mountandblade2.py @@ -0,0 +1,100 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_game import BasicGame, BasicGameSaveGame + + +class MountAndBladeIIModDataChecker(mobase.ModDataChecker): + _valid_folders: list[str] = [ + "native", + "sandbox", + "sandboxcore", + "storymode", + "custombattle", + ] + + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for e in filetree: + if e.isDir(): + if e.name().lower() in self._valid_folders: + return mobase.ModDataChecker.VALID + if e.exists("SubModule.xml", mobase.IFileTree.FILE): # type: ignore + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class MountAndBladeIIGame(BasicGame): + Name = "Mount & Blade II: Bannerlord" + Author = "Holt59" + Version = "0.1.1" + Description = "Adds support for Mount & Blade II: Bannerlord" + + GameName = "Mount & Blade II: Bannerlord" + GameShortName = "mountandblade2bannerlord" + GameDataPath = "Modules" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Mount-&-Blade-II:-Bannerlord" + ) + + GameBinary = "bin/Win64_Shipping_Client/TaleWorlds.MountAndBlade.Launcher.exe" + + GameDocumentsDirectory = "%DOCUMENTS%/Mount and Blade II Bannerlord/Configs" + GameSavesDirectory = "%DOCUMENTS%/Mount and Blade II Bannerlord/Game Saves" + + GameNexusId = 3174 + GameSteamId = 261550 + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(MountAndBladeIIModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + save_paths = list(Path(folder.absolutePath()).glob("*.sav")) + list( + Path(folder.absolutePath()).glob("*.sav.cleaner_backup_*") + ) + return [BasicGameSaveGame(path) for path in save_paths] + + def executables(self): + return [ + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord (Launcher)", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/TaleWorlds.MountAndBlade.Launcher.exe", + ), + ), + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/Bannerlord.exe", + ), + ), + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord (Native)", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/Bannerlord.Native.exe", + ), + ), + mobase.ExecutableInfo( + "Mount & Blade II: Bannerlord (BE)", + QFileInfo( + self.gameDirectory(), + "bin/Win64_Shipping_Client/Bannerlord_BE.exe", + ), + ), + ] diff --git a/libs/basic_games/games/game_msfs2020.py b/libs/basic_games/games/game_msfs2020.py new file mode 100644 index 0000000..d0c91ba --- /dev/null +++ b/libs/basic_games/games/game_msfs2020.py @@ -0,0 +1,34 @@ +import os +import re + +from PyQt6.QtCore import QDir + +from ..basic_game import BasicGame + + +class MSFS2020Game(BasicGame): + Name = "Microsoft Flight Simulator 2020 Support Plugin" + Author = "Deorder" + Version = "0.0.1" + + GameName = "Microsoft Flight Simulator 2020" + GameShortName = "msfs2020" + GameBinary = r"FlightSimulator.exe" + GameSteamId = [1250410] + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Microsoft-Flight-Simulator-(2020)" + ) + + def dataDirectory(self) -> QDir: + # Find and use package path specified in Asobo engine options + AppDataPath = os.path.expandvars(r"%APPDATA%\Microsoft Flight Simulator") + UserCfgPath = os.path.join(AppDataPath, "UserCfg.opt") + InstalledPackagesPathPattern = re.compile( + r'InstalledPackagesPath\s*=\s*"(.*)"', re.IGNORECASE + ) + with open(UserCfgPath, newline="") as f: + for _, line in enumerate(f): + for match in re.finditer(InstalledPackagesPathPattern, line): + return QDir(os.path.join(match.group(), "Community")) + return QDir(os.path.join(AppDataPath, "Packages", "Community")) diff --git a/libs/basic_games/games/game_nfshs.py b/libs/basic_games/games/game_nfshs.py new file mode 100644 index 0000000..ef24234 --- /dev/null +++ b/libs/basic_games/games/game_nfshs.py @@ -0,0 +1,28 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class NFSHSGame(BasicGame): + Name = "Need for Speed: High Stakes Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Need for Speed: High Stakes" + GameShortName = "nfshs" + GameNexusName = "needforspeedhighstakes" + GameNexusId = 6032 + GameBinary = "nfshs.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Need for Speed: High Stakes", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ] diff --git a/libs/basic_games/games/game_nierautomata.py b/libs/basic_games/games/game_nierautomata.py new file mode 100644 index 0000000..3026fb1 --- /dev/null +++ b/libs/basic_games/games/game_nierautomata.py @@ -0,0 +1,14 @@ +from ..basic_game import BasicGame + + +class NierAutomataGame(BasicGame): + Name = "NieR:Automata Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "NieR:Automata" + GameShortName = "nierautomata" + GameNexusName = "nierautomata" + GameSteamId = 524220 + GameBinary = "NieRAutomata.exe" + GameDataPath = "" diff --git a/libs/basic_games/games/game_nomanssky.py b/libs/basic_games/games/game_nomanssky.py new file mode 100644 index 0000000..651a326 --- /dev/null +++ b/libs/basic_games/games/game_nomanssky.py @@ -0,0 +1,38 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class NoMansSkyGame(BasicGame): + Name = "No Man's Sky Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "1.0.0" + + GameName = "No Man's Sky" + GameShortName = "nomanssky" + GameNexusName = "nomanssky" + GameNexusId = 1634 + GameSteamId = 275850 + GameGogId = 1446213994 + GameBinary = "Binaries/NMS.exe" + GameDataPath = "GAMEDATA/MODS" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-No-Man's-Sky" + ) + GameSavesDirectory = "%USERPROFILE%/AppData/Roaming/HelloGames/NMS" + GameSaveExtension = "hg" + + def executables(self): + return [ + mobase.ExecutableInfo( + "No Man's Sky", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "No Man's Sky VR", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-HmdEnable 1"), + ] diff --git a/libs/basic_games/games/game_oblivion_remaster.py b/libs/basic_games/games/game_oblivion_remaster.py new file mode 100644 index 0000000..218bb04 --- /dev/null +++ b/libs/basic_games/games/game_oblivion_remaster.py @@ -0,0 +1,441 @@ +import json +import os.path +import shutil +import winreg +from enum import IntEnum, auto +from pathlib import Path +from typing import cast + +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths +from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_game import BasicGame +from .oblivion_remaster.constants import DEFAULT_UE4SS_MODS, PLUGIN_NAME, UE4SSModInfo +from .oblivion_remaster.paks.widget import PaksTabWidget +from .oblivion_remaster.ue4ss.widget import UE4SSTabWidget + + +def getLootPath() -> Path | None: + """ + Parse the LOOT path using either the modern InnoSetup registry entries (local vs. global installs) or the + old registry path. + """ + paths = [ + ( + winreg.HKEY_CURRENT_USER, + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1", + "InstallLocation", + ), + ( + winreg.HKEY_LOCAL_MACHINE, + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{BF634210-A0D4-443F-A657-0DCE38040374}_is1", + "InstallLocation", + ), + (winreg.HKEY_LOCAL_MACHINE, "Software\\LOOT", "Installed Path"), + ] + + for path in paths: + try: + with winreg.OpenKeyEx( + path[0], + path[1], + ) as key: + value = winreg.QueryValueEx(key, path[2]) + return Path((value[0] + "/LOOT.exe").replace("/", "\\")) + except FileNotFoundError: + pass + return None + + +class Problems(IntEnum): + """ + Enums for IPluginDiagnose. + """ + + # The 'dwmapi.dll' is present in the game EXE directory. + UE4SS_LOADER = auto() + # A UE4SS mod has a space in the directory name. + INVALID_UE4SS_MOD_NAME = auto() + + +class OblivionRemasteredGame( + BasicGame, mobase.IPluginFileMapper, mobase.IPluginDiagnose +): + Name = PLUGIN_NAME + Author = "Silarn" + Version = "0.1.0-b.1" + Description = "TES IV: Oblivion Remastered; an unholy hybrid of Bethesda and Unreal" + + GameName = "Oblivion Remastered" + GameShortName = "oblivionremastered" + GameNexusId = 7587 + GameSteamId = 2623190 + GameBinary = "OblivionRemastered.exe" + GameDataPath = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData\Data" + GameDocumentsDirectory = r"%GAME_PATH%\OblivionRemastered\Content\Dev\ObvData" + UserHome = QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.HomeLocation + ) + # Oblivion Remastered does not use the expanded Documents path but instead always uses the + # base user directory path, even when this disagrees with Windows. + MyDocumentsDirectory = rf"{UserHome}\Documents\My Games\{GameName}" + GameSavesDirectory = rf"{MyDocumentsDirectory}\Saved\SaveGames" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Elder-Scrolls-IV:-Oblivion-Remastered" + ) + + _main_window: QMainWindow + _ue4ss_tab: UE4SSTabWidget + _paks_tab: PaksTabWidget + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + mobase.IPluginDiagnose.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + from .oblivion_remaster.game_plugins import OblivionRemasteredGamePlugins + from .oblivion_remaster.mod_data_checker import OblivionRemasteredModDataChecker + from .oblivion_remaster.mod_data_content import OblivionRemasteredDataContent + from .oblivion_remaster.script_extender import OblivionRemasteredScriptExtender + + super().init(organizer) + self._register_feature(BasicGameSaveGameInfo()) + self._register_feature(OblivionRemasteredGamePlugins(self._organizer)) + self._register_feature(OblivionRemasteredModDataChecker(self._organizer)) + self._register_feature(OblivionRemasteredScriptExtender(self)) + self._register_feature(OblivionRemasteredDataContent()) + + organizer.onUserInterfaceInitialized(self.init_tab) + return True + + def init_tab(self, main_window: QMainWindow): + """ + Initializes tabs unique to Oblivion Remastered. + The "UE4SS Mods" tab and "Paks" tab. + """ + if self._organizer.managedGame() != self: + return + + self._main_window = main_window + tab_widget: QTabWidget = main_window.findChild(QTabWidget, "tabWidget") + if not tab_widget or not tab_widget.findChild(QWidget, "espTab"): + return + + self._ue4ss_tab = UE4SSTabWidget(main_window, self._organizer) + + # Find the default "Plugins" tab + plugin_tab = tab_widget.findChild(QWidget, "espTab") + tab_index = tab_widget.indexOf(plugin_tab) + 1 + # The "Bethesda Plugins Manager" plugin hides the default Plugins tab and inserts itself after. + # If the default tab is hidden, increment our position by one to account for it. + if not tab_widget.isTabVisible(tab_widget.indexOf(plugin_tab)): + tab_index += 1 + tab_widget.insertTab(tab_index, self._ue4ss_tab, "UE4SS Mods") + + self._paks_tab = PaksTabWidget(main_window, self._organizer) + + tab_index += 1 + tab_widget.insertTab(tab_index, self._paks_tab, "Paks") + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "ue4ss_use_root_builder", "Use Root Builder paths for UE4SS mods", False + ) + ] + + def executables(self): + execs = [ + mobase.ExecutableInfo( + "Oblivion Remastered", + QFileInfo( + QDir( + self.gameDirectory().absoluteFilePath( + "OblivionRemastered/Binaries/Win64" + ) + ), + "OblivionRemastered-Win64-Shipping.exe", + ), + ) + ] + if extender := self._organizer.gameFeatures().gameFeature( + mobase.ScriptExtender + ): + execs.append( + mobase.ExecutableInfo("OBSE64", QFileInfo(extender.loaderPath())) # type: ignore + ) + if lootPath := getLootPath(): + execs.append( + mobase.ExecutableInfo("LOOT", QFileInfo(str(lootPath))).withArgument( + '--game="Oblivion Remastered"' + ) + ) + if magicLoaderPath := self.gameDirectory().absoluteFilePath( + "MagicLoader/MagicLoader.exe" + ): + execs.append( + mobase.ExecutableInfo("MagicLoader", QFileInfo(magicLoaderPath)) + ) + + return execs + + def primaryPlugins(self) -> list[str]: + return [ + "Oblivion.esm", + "DLCBattlehornCastle.esp", + "DLCFrostcrag.esp", + "DLCHorseArmor.esp", + "DLCMehrunesRazor.esp", + "DLCOrrery.esp", + "DLCShiveringIsles.esp", + "DLCSpellTomes.esp", + "DLCThievesDen.esp", + "DLCVileLair.esp", + "Knights.esp", + "AltarESPMain.esp", + "AltarDeluxe.esp", + "AltarESPLocal.esp", # Not actually shipped with the game files but present in plugins.txt. + ] + + def modDataDirectory(self) -> str: + return "Data" + + def moviesDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Movies" + ) + + def paksDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absolutePath() + "/OblivionRemastered/Content/Paks" + ) + + def exeDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absolutePath() + "/OblivionRemastered/Binaries/Win64" + ) + + def obseDirectory(self) -> QDir: + return QDir(self.exeDirectory().absolutePath() + "/OBSE") + + def ue4ssDirectory(self) -> QDir: + return QDir(self.exeDirectory().absolutePath() + "/ue4ss/Mods") + + def loadOrderMechanism(self) -> mobase.LoadOrderMechanism: + return mobase.LoadOrderMechanism.PLUGINS_TXT + + def sortMechanism(self) -> mobase.SortMechanism: + return mobase.SortMechanism.LOOT + + def initializeProfile( + self, directory: QDir, settings: mobase.ProfileSetting + ) -> None: + if settings & mobase.ProfileSetting.CONFIGURATION: + game_ini_file = self.gameDirectory().absoluteFilePath( + r"OblivionRemastered\Content\Dev\ObvData\Oblivion.ini" + ) + game_default_ini = self.gameDirectory().absoluteFilePath( + r"OblivionRemastered\Content\Dev\ObvData\Oblivion_default.ini" + ) + profile_ini = directory.absoluteFilePath( + QFileInfo("Oblivion.ini").fileName() + ) + if not os.path.exists(profile_ini): + try: + shutil.copyfile( + game_ini_file, + profile_ini, + ) + except FileNotFoundError: + if os.path.exists(game_ini_file): + shutil.copyfile( + game_default_ini, + profile_ini, + ) + else: + Path(profile_ini).touch() + # Initialize a default UE4SS mods.ini and mods.json with the core mods included + self.write_default_mods(directory) + # Bootstrap common mod directories used by the USVFS map + if ( + self._organizer.managedGame() + and self._organizer.managedGame().gameName() == self.gameName() + ): + if not self.paksDirectory().exists(): + os.makedirs(self.paksDirectory().absolutePath()) + if not self.obseDirectory().exists(): + os.makedirs(self.obseDirectory().absolutePath()) + if not self.ue4ssDirectory().exists(): + os.makedirs(self.ue4ssDirectory().absolutePath()) + + def write_default_mods(self, profile: QDir): + """ + Writer for the default UE4SS 'mods.txt' and 'mods.json' profile files. + """ + + ue4ss_mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt")) + ue4ss_mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + if not ue4ss_mods_txt.exists(): + with open(ue4ss_mods_txt.absoluteFilePath(), "w") as mods_txt: + for mod in DEFAULT_UE4SS_MODS: + mods_txt.write(f"{mod['mod_name']} : 1\n") + if not ue4ss_mods_json.exists(): + mods_data: list[UE4SSModInfo] = [] + for mod in DEFAULT_UE4SS_MODS: + mods_data.append({"mod_name": mod["mod_name"], "mod_enabled": True}) + with open(ue4ss_mods_json.absoluteFilePath(), "w") as mods_json: + mods_json.write(json.dumps(mods_data, indent=4)) + + def iniFiles(self) -> list[str]: + return ["Oblivion.ini"] + + def mappings(self) -> list[mobase.Mapping]: + mappings: list[mobase.Mapping] = [] + for profile_file in ["plugins.txt", "loadorder.txt"]: + mappings.append( + mobase.Mapping( + self._organizer.profilePath() + "/" + profile_file, + self.dataDirectory().absolutePath() + "/" + profile_file, + False, + ) + ) + for profile_file in ["mods.txt", "mods.json"]: + mappings.append( + mobase.Mapping( + self._organizer.profilePath() + "/" + profile_file, + self.ue4ssDirectory().absolutePath() + "/" + profile_file, + False, + ) + ) + return mappings + + def getModMappings(self) -> dict[str, list[str]]: + return { + "Data": [self.dataDirectory().absolutePath()], + "Paks": [self.paksDirectory().absolutePath()], + "OBSE": [self.obseDirectory().absolutePath()], + "Movies": [self.moviesDirectory().absolutePath()], + "UE4SS": [self.ue4ssDirectory().absolutePath()], + "GameSettings": [self.exeDirectory().absoluteFilePath("GameSettings")], + } + + def activeProblems(self) -> list[int]: + if self._organizer.managedGame() == self: + problems: set[Problems] = set() + # The dwmapi.dll loader should not be used with USVFS + ue4ss_loader = QFileInfo(self.exeDirectory().absoluteFilePath("dwmapi.dll")) + if ue4ss_loader.exists(): + problems.add(Problems.UE4SS_LOADER) + # Leverage UE4SS mod tab to find mod names with spaces + for mod in self._ue4ss_tab.get_mod_list(): + if " " in mod: + problems.add(Problems.INVALID_UE4SS_MOD_NAME) + break + return list(problems) + return [] + + def fullDescription(self, key: int) -> str: + match key: + case Problems.UE4SS_LOADER: + return ( + "The UE4SS loader DLL is present (dwmapi.dll). This will not function out-of-the box with MO2's virtual filesystem.\n\n" + + "In order to resolve this, either delete the DLL and use the OBSE UE4SS Loader plugin, or rename " + + "the DLL (ex. 'ue4ss_loader.dll') and set it to force load with the game exe.\n\n" + + "Do this for any executable which runs the game, such as the OBSE64 loader." + ) + case Problems.INVALID_UE4SS_MOD_NAME: + return ( + "UE4SS mods do not load properly with spaces in the mod name. These are stripped when parsing mods.txt and then" + "fail to match up when parsing the mods.json. Simply remove the spaces and they should load correctly." + ) + case _: + return "" + + def hasGuidedFix(self, key: int) -> bool: + match key: + case Problems.UE4SS_LOADER: + return True + case Problems.INVALID_UE4SS_MOD_NAME: + return True + case _: + return False + + def shortDescription(self, key: int) -> str: + match key: + case Problems.UE4SS_LOADER: + return "The UE4SS loader DLL is present (dwmapi.dll)." + case Problems.INVALID_UE4SS_MOD_NAME: + return "A UE4SS mod name contains a space." + case _: + return "" + + def startGuidedFix(self, key: int) -> None: + match key: + case Problems.UE4SS_LOADER: + os.rename( + self.exeDirectory().absoluteFilePath("dwmapi.dll"), + self.exeDirectory().absoluteFilePath("ue4ss_loader.dll"), + ) + case Problems.INVALID_UE4SS_MOD_NAME: + for mod in self._organizer.modList().allMods(): + filetree = self._organizer.modList().getMod(mod).fileTree() + ue4ss_mod = filetree.find("UE4SS") + if not ue4ss_mod: + filetree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_mod, mobase.IFileTree): + for entry in ue4ss_mod: + if isinstance(entry, mobase.IFileTree) and entry.find( + "scripts/main.lua" + ): + if " " in entry.name(): + mod_dir = Path( + self._organizer.modList() + .getMod(mod) + .absolutePath() + ) + mod_path = mod_dir.joinpath(entry.path("/")) + fixed_path = mod_dir.joinpath( + cast(mobase.IFileTree, entry.parent()).path( + "/" + ), + entry.name().replace(" ", ""), + ) + try: + mod_path.rename(fixed_path) + self._organizer.modDataChanged( + self._organizer.modList().getMod(mod) + ) + self._ue4ss_tab.update_mod_files(mod) + except FileExistsError: + pass + except FileNotFoundError: + pass + for entry in self.ue4ssDirectory().entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + entry_dir = QDir(entry.absoluteFilePath()) + if QFileInfo( + entry_dir.absoluteFilePath("scripts/main.lua") + ).exists(): + if " " in entry_dir.dirName(): + dest = ( + entry_dir.absoluteFilePath("..") + + "/" + + entry_dir.dirName().replace(" ", "") + ) + try: + os.rename(entry_dir.absolutePath(), dest) + except FileExistsError: + pass + except FileNotFoundError: + pass + case _: + pass diff --git a/libs/basic_games/games/game_readyornot.py b/libs/basic_games/games/game_readyornot.py new file mode 100644 index 0000000..d436e38 --- /dev/null +++ b/libs/basic_games/games/game_readyornot.py @@ -0,0 +1,24 @@ +from ..basic_game import BasicGame + + +class ReadyOrNotGame(BasicGame): + Name = "Ready or Not Support Plugin" + Author = "Ra2-IFV" + Version = "0.0.0.1" + + GameName = "Ready or Not" + GameShortName = "readyornot" + GameNexusName = "readyornot" + GameValidShortNames = ["ron"] + # GameNexusId = "readyornot" + GameBinary = "ReadyOrNot/Binaries/Win64/ReadyOrNot-Win64-Shipping.exe" + GameLauncher = "ReadyOrNot.exe" + GameDataPath = "ReadyOrNot/Content/Paks" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/ReadyOrNot" + GameIniFiles = [ + "%GAME_DOCUMENTS%/Saved/Config/Windows/Game.ini", + "%GAME_DOCUMENTS%/Saved/Config/Windows/GameUserSettings.ini", + ] + GameSavesDirectory = "%USERPROFILE%/AppData/Local/ReadyOrNot/Saved/SaveGames" + GameSaveExtension = "sav" + GameSteamId = 1144200 diff --git a/libs/basic_games/games/game_schedule1.py b/libs/basic_games/games/game_schedule1.py new file mode 100644 index 0000000..8ed98d6 --- /dev/null +++ b/libs/basic_games/games/game_schedule1.py @@ -0,0 +1,112 @@ +import json +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) +from ..basic_game import BasicGame + + +def parse_schedule1_save_metadata(save_path: Path, save: mobase.ISaveGame): + metadata_file = save_path / "Game.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file) + name = meta_data["OrganisationName"] + if name != (save_name := save.getName()): + name = f"{save_name} ({name})" + return { + "Name": name, + "Game version": meta_data["GameVersion"], + } + except (FileNotFoundError, json.JSONDecodeError): + return None + + +class Schedule1SaveGame(BasicGameSaveGame): + def getName(self) -> str: + metadata_file = self._filepath / "Game.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file) + return meta_data["OrganisationName"] + except (FileNotFoundError, json.JSONDecodeError): + return ( + f"[{self.getSaveGroupIdentifier().rstrip('s')}] {self._filepath.stem}" + ) + + def getSaveGroupIdentifier(self) -> str: + return self._filepath.parent.name + + +class Schedule1Game(BasicGame): + Name = "Schedule I Support Plugin" + Author = "shellbj" + Version = "1.0.0" + + GameName = "Schedule I" + GameShortName = "scheduleI" + GameNexusName = "schedule1" + GameNexusId = 7381 + GameSteamId = 3164500 + + GameBinary = "Schedule I.exe" + GameValidShortNames = ["schedule1", "scheduleI"] + GameDataPath = "" + GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/TVGS/Schedule I/Saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Schedule-I" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature( + BasicModDataChecker( + GlobPatterns( + unfold=[ + # Fixes bad packaging for select mods + "ModManager&PhoneApp", + "AutoClearCompletedDeals", + ], + valid=[ + "meta.ini", # Included in installed mod folder. + "Mods", + "Plugins", + "UserData", # might not be needed + ], + delete=[ + "*.md", + "icon.png", + "fomod", # not sure this is needed either + ], + move={ + "*.dll": "Mods/", + # If these even exisit outside of mod packs + "*.ini": "UserData/", + "*.cfg": "UserData/", + "*.json": "UserData/", + }, + ) + ) + ) + self._register_feature(BasicLocalSavegames(self)) + self._register_feature( + BasicGameSaveGameInfo( + None, # no snapshot to add to the widget + parse_schedule1_save_metadata, + ) + ) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + return [ + Schedule1SaveGame(path) + for path in Path(folder.absolutePath()).glob("*/SaveGame_[1-5]") + ] diff --git a/libs/basic_games/games/game_sekiroshadowsdietwice.py b/libs/basic_games/games/game_sekiroshadowsdietwice.py new file mode 100644 index 0000000..5e7aaf0 --- /dev/null +++ b/libs/basic_games/games/game_sekiroshadowsdietwice.py @@ -0,0 +1,14 @@ +from ..basic_game import BasicGame + + +class SekiroShadowsDieTwiceGame(BasicGame): + Name = "Sekiro: Shadows Die Twice Support Plugin" + Author = "Kane Dou" + Version = "1.0.0" + + GameName = "Sekiro: Shadows Die Twice" + GameShortName = "sekiro" + GameBinary = "sekiro.exe" + GameDataPath = "mods" + GameSaveExtension = "sl2" + GameSteamId = 814380 diff --git a/libs/basic_games/games/game_silenthill2remake.py b/libs/basic_games/games/game_silenthill2remake.py new file mode 100644 index 0000000..4683a5d --- /dev/null +++ b/libs/basic_games/games/game_silenthill2remake.py @@ -0,0 +1,113 @@ +from typing import Tuple + +import mobase + +from ..basic_features import BasicModDataChecker, GlobPatterns +from ..basic_game import BasicGame + + +class SilentHill2RemakeModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + delete=[ + "*.txt", + "*.md", + "manifest.json", + "icon.png", + ], + ) + ) + self.mod_path = ["SHProto", "Content", "Paks", "~mod"] + self.mod_path_lower = [name.lower() for name in self.mod_path] + + def _find_tree( + self, filetree: mobase.IFileTree + ) -> Tuple[str | None, mobase.FileTreeEntry | None]: + """ + Search the given filetree for a directory name that matches any component + of self.mod_path (case-insensitive). + + Returns: + (prefix, entry) + prefix: The missing part before the match (e.g. 'SHProto/Content/') + entry: The IFileTree entry that matched (e.g. the 'Paks' directory) + (None, None) if nothing matches. + """ + for entry in filetree: + if not entry.isDir(): + continue + + name_lower = entry.name().lower() + for i, component in enumerate(self.mod_path_lower): + if name_lower == component: + # Build the prefix string for everything *before* this match + prefix_parts = self.mod_path[:i] + prefix = "/".join(prefix_parts) + ("/" if prefix_parts else "") + return (prefix, entry) + # No matches found + return (None, None) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check for fully valid layout + has_entry, _ = self._find_tree(filetree) + if has_entry is None: + # in this case we check to make sure there's a .pak file + for entry in filetree: + if entry.name().lower().endswith(".pak") and entry.isFile(): + return mobase.ModDataChecker.FIXABLE + elif has_entry == "": + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.FIXABLE + + # Otherwise, not recognizable + return mobase.ModDataChecker.INVALID + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + filetree = super().fix(filetree) + prefix, item = self._find_tree(filetree) + if prefix is None: + foundAPak = False + # Move all top-level items to BepInEx/plugins/ + items_to_move = list(filetree) + for cur_item in items_to_move: + if cur_item.name().lower().endswith(".pak"): + foundAPak = True + filetree.move(cur_item, f"SHProto/Content/Paks/~mod/{cur_item.name()}") + # foundAPack MUST be true because if 'prefix' returned None then + # there must be a .pak file or dataLooksValid wouldn't have returned + # a FIXABLE. This is therefore just a sanity check + assert foundAPak + return filetree + elif prefix == "": + return filetree + else: + # if prefix is not None then item cannot be None + assert item is not None + filetree.move(item, f"{prefix}{item.name()}") + return filetree + + +class SilentHill2RemakeGame(BasicGame): + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(SilentHill2RemakeModDataChecker()) + return True + + Name = "Silent Hill 2 Remake Support Plugin" + Author = "HomerSimpleton Returns" + Version = "1.0" + + GameName = "Silent Hill 2 Remake" + GameShortName = "silenthill2" + GameNexusName = "silenthill2" + + GameBinary = "SHProto/Binaries/Win64/SHProto-Win64-Shipping.exe" + GameLauncher = "SHProto.exe" + GameDataPath = "%GAME_PATH%" + GameSupportURL = "https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Silent-Hill-2-Remake" + + GameGogId = [1225972913, 2051029707] diff --git a/libs/basic_games/games/game_silksong.py b/libs/basic_games/games/game_silksong.py new file mode 100644 index 0000000..12aab55 --- /dev/null +++ b/libs/basic_games/games/game_silksong.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class SilksongModDataChecker(BasicModDataChecker): + def __init__(self, patterns: GlobPatterns | None = None): + super().__init__( + GlobPatterns( + valid=[ + # BepInEx files go to root + "BepInEx", + "doorstop_config.ini", + "winhttp.dll", + ".doorstop_version", + ], + delete=[ + "*.txt", + "*.md", + "manifest.json", + "icon.png", + ], + ).merge(patterns or GlobPatterns()), + ) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # Check if it contains BepInEx folders/files - if so, keep structure + if self._has_bepinex_structure(filetree): + return self.VALID + + # Everything else needs to go to BepInEx/plugins/ + return self.FIXABLE + + def _has_bepinex_structure(self, filetree: mobase.IFileTree) -> bool: + """Check if the mod has BepInEx folder structure""" + for entry in filetree: + name = entry.name().lower() + if name in [ + "bepinex", + "doorstop_config.ini", + "winhttp.dll", + ".doorstop_version", + ]: + return True + return False + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + # First apply the basic fix (handles delete patterns) + filetree = super().fix(filetree) + + # If no BepInEx structure, move everything to BepInEx/plugins/ + if not self._has_bepinex_structure(filetree): + # Move all top-level items to BepInEx/plugins/ + items_to_move = list(filetree) + for item in items_to_move: + filetree.move(item, f"BepInEx/plugins/{item.name()}") + + return filetree + + +class SilksongGame(BasicGame): + Name = "Hollow Knight: Silksong Support Plugin" + Author = "Nikirack" + Version = "1.0.0" + + GameName = "Hollow Knight: Silksong" + GameShortName = "hollowknightsilksong" # Match the error message + GameNexusName = "hollowknightsilksong" + GameSteamId = 1030300 + GameBinary = r"Hollow Knight Silksong.exe" + GameDataPath = "" + GameSavesDirectory = ( + r"%USERPROFILE%\AppData\LocalLow\Team Cherry\Hollow Knight Silksong" + ) + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Hollow-Knight-Silksong" + ) + + _forced_libraries = ["winhttp.dll"] + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(SilksongModDataChecker()) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + saves: list[mobase.ISaveGame] = [] + save_dir = Path(folder.absolutePath()) + + if save_dir.exists(): + # Look for common save file patterns + for pattern in ["*.save", "user*.dat"]: + for save_file in save_dir.rglob(pattern): + saves.append(BasicGameSaveGame(save_file)) + + return saves + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + self.gameName(), + self.gameDirectory().absoluteFilePath(self.binaryName()), + ) + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + return [ + mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True) + for lib in self._forced_libraries + ] diff --git a/libs/basic_games/games/game_sims4.py b/libs/basic_games/games/game_sims4.py new file mode 100644 index 0000000..e5e7fa2 --- /dev/null +++ b/libs/basic_games/games/game_sims4.py @@ -0,0 +1,153 @@ +from collections.abc import Callable +from enum import IntEnum +from re import match +from typing import Any, List, Set, cast + +from mobase import ( + FileTreeEntry, + IFileTree, + IOrganizer, + ModDataChecker, + ModDataContent, + ReleaseType, + VersionInfo, +) + +from ..basic_game import BasicGame + + +class Content(IntEnum): + PACKAGE = 0 + SCRIPT = 1 + + +class ValidationResult(IntEnum): + INVALID = 0 + VALID = 1 + FIXABLE = 2 + + +class TS4Game(BasicGame): + Name = "The Sims 4 Support Plugin" + Author = "R3z Shark, xieve" + GameName = "The Sims 4" + GameShortName = "thesims4" + GameBinary = "Game/Bin/TS4_x64.exe" + GameDataPath = "%DOCUMENTS%/Electronic Arts/The Sims 4/Mods" + GameSteamId = 1222670 + GameOriginManifestIds = ["OFB-EAST:109552677"] + GameOriginWatcherExecutables = ("TS4_x64.exe",) + + def version(self): + return VersionInfo(1, 0, 0, ReleaseType.FINAL) + + def documentsDirectory(self): + return self.dataDirectory() + + def init(self, organizer: IOrganizer): + if super().init(organizer): + self._register_feature(TS4ModDataChecker()) + self._register_feature(TS4ModDataContent()) + return True + return False + + +class TS4ModDataChecker(ModDataChecker): + # .package files are allowed at a maximum depth of 5 subfolders, script files can be at most one level deep + # The first capturing group lazily captures any parent folders exceeding that depth, see below + _fixableOrValid = r"(?i)^(.*?)((?:[^\\]+\\){0,5}[^\\]*\.package|(?:[^\\]+\\)?[^\\]*\.ts4script|(?:[^\\]+\\)scripts\\.*\.py)$" + + def dataLooksValid(self, filetree: IFileTree) -> ModDataChecker.CheckReturn: + return cast( + ModDataChecker.CheckReturn, + self._fixOrValidateTree(filetree, validateMode=True), + ) + + def fix(self, filetree: IFileTree) -> IFileTree | None: + return cast(IFileTree, self._fixOrValidateTree(filetree)) + + def _fixOrValidateTree( + self, + tree: IFileTree, + validateMode: bool = False, + ) -> IFileTree | ModDataChecker.CheckReturn: + validationResult = ValidationResult.INVALID + checkReturn = ModDataChecker.INVALID + walkReturn = IFileTree.CONTINUE + + def setValidationResult( + newResult: ValidationResult = ValidationResult.INVALID, + actionCallback: Callable[[], Any] = lambda: None, + ): + nonlocal validationResult + nonlocal checkReturn + nonlocal walkReturn + if validateMode: + validationResult = max(validationResult, newResult) + match validationResult: + case ValidationResult.INVALID: + pass + case ValidationResult.VALID: + checkReturn = ModDataChecker.VALID + case ValidationResult.FIXABLE: + checkReturn = ModDataChecker.FIXABLE + walkReturn = IFileTree.STOP + else: + actionCallback() + + def fixOrValidateEntry( + parentPath: str, entry: FileTreeEntry + ) -> IFileTree.WalkReturn: + path = f"{parentPath}{entry.name()}" + fixableOrValid = match(self._fixableOrValid, path) + if fixableOrValid: + match fixableOrValid.groups(): + case ["", _]: + setValidationResult(ValidationResult.VALID) + case [_, innerPath]: + # Move files that are nested too deeply up, preserving the inner folder structure + # E.g. a/b/c.ts4script will be moved to b/c.ts4script + setValidationResult( + ValidationResult.FIXABLE, + lambda: tree.move(entry, innerPath), + ) + case _: + pass + return walkReturn + + tree.walk(fixOrValidateEntry) + + if validateMode: + return checkReturn + else: + return tree + + +class TS4ModDataContent(ModDataContent): + def getAllContents(self: ModDataContent) -> List[ModDataContent.Content]: + return [ + ModDataContent.Content( + Content.PACKAGE, "Package", ":/MO/gui/content/plugin" + ), + ModDataContent.Content(Content.SCRIPT, "Script", ":/MO/gui/content/script"), + ] + + def getContentsFor(self: ModDataContent, filetree: IFileTree) -> List[int]: + contents: Set[int] = set() + + def getContentForEntry(path: str, entry: FileTreeEntry): + nonlocal contents + match entry.suffix(): + case "package": + contents.add(Content.PACKAGE) + case "ts4script" | "py": + contents.add(Content.SCRIPT) + case _: + pass + if len(contents) == 2: + return IFileTree.STOP + else: + return IFileTree.CONTINUE + + filetree.walk(getContentForEntry) + return list(contents) diff --git a/libs/basic_games/games/game_stalkeranomaly.py b/libs/basic_games/games/game_stalkeranomaly.py new file mode 100644 index 0000000..bb40575 --- /dev/null +++ b/libs/basic_games/games/game_stalkeranomaly.py @@ -0,0 +1,293 @@ +from enum import IntEnum +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo, Qt +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget + +import mobase + +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) +from ..basic_game import BasicGame +from .stalkeranomaly import XRSave + + +class StalkerAnomalyModDataChecker(mobase.ModDataChecker): + _valid_folders: list[str] = [ + "appdata", + "bin", + "db", + "gamedata", + ] + + def hasValidFolders(self, tree: mobase.IFileTree) -> bool: + for e in tree: + if e.isDir(): + if e.name().lower() in self._valid_folders: + return True + + return False + + def findLostData(self, tree: mobase.IFileTree) -> list[mobase.FileTreeEntry]: + lost_db: list[mobase.FileTreeEntry] = [] + + for e in tree: + if e.isFile(): + if e.suffix().lower().startswith("db"): + lost_db.append(e) + + return lost_db + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + if self.hasValidFolders(filetree): + return mobase.ModDataChecker.VALID + + if self.findLostData(filetree): + return mobase.ModDataChecker.FIXABLE + + return mobase.ModDataChecker.INVALID + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + lost_db = self.findLostData(filetree) + if lost_db: + rfolder = filetree.addDirectory("db").addDirectory("mods") + for r in lost_db: + rfolder.insert(r, mobase.IFileTree.REPLACE) + + return filetree + + +class Content(IntEnum): + INTERFACE = 0 + TEXTURE = 1 + MESH = 2 + SCRIPT = 3 + SOUND = 4 + MCM = 5 + CONFIG = 6 + + +class StalkerAnomalyModDataContent(mobase.ModDataContent): + content: list[int] = [] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content( + Content.INTERFACE, "Interface", ":/MO/gui/content/interface" + ), + mobase.ModDataContent.Content( + Content.TEXTURE, "Textures", ":/MO/gui/content/texture" + ), + mobase.ModDataContent.Content( + Content.MESH, "Meshes", ":/MO/gui/content/mesh" + ), + mobase.ModDataContent.Content( + Content.SCRIPT, "Scripts", ":/MO/gui/content/script" + ), + mobase.ModDataContent.Content( + Content.SOUND, "Sounds", ":/MO/gui/content/sound" + ), + mobase.ModDataContent.Content(Content.MCM, "MCM", ":/MO/gui/content/menu"), + mobase.ModDataContent.Content( + Content.CONFIG, "Configs", ":/MO/gui/content/inifile" + ), + ] + + def walkContent( + self, path: str, entry: mobase.FileTreeEntry + ) -> mobase.IFileTree.WalkReturn: + name = entry.name().lower() + if entry.isFile(): + ext = entry.suffix().lower() + if ext in ["dds", "thm"]: + self.content.append(Content.TEXTURE) + if path.startswith("gamedata/textures/ui"): + self.content.append(Content.INTERFACE) + elif ext in ["omf", "ogf"]: + self.content.append(Content.MESH) + elif ext in ["script"]: + self.content.append(Content.SCRIPT) + if "_mcm" in name: + self.content.append(Content.MCM) + elif ext in ["ogg"]: + self.content.append(Content.SOUND) + elif ext in ["ltx", "xml"]: + self.content.append(Content.CONFIG) + if path.startswith("gamedata/configs/ui"): + self.content.append(Content.INTERFACE) + + return mobase.IFileTree.WalkReturn.CONTINUE + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + self.content = [] + filetree.walk(self.walkContent, "/") + return self.content + + +class StalkerAnomalySaveGame(BasicGameSaveGame): + _filepath: Path + + xr_save: XRSave + + def __init__(self, filepath: Path): + super().__init__(filepath) + self._filepath = filepath + self.xr_save = XRSave(self._filepath) + + def getName(self) -> str: + xr_save = self.xr_save + player = xr_save.player + if player: + name = player.character_name_str + time = xr_save.time_fmt + return f"{name}, {xr_save.save_fmt} [{time}]" + return "" + + def allFiles(self) -> list[str]: + filepath = str(self._filepath) + paths = [filepath] + scoc = filepath.replace(".scop", ".scoc") + if Path(scoc).exists(): + paths.append(scoc) + dds = filepath.replace(".scop", ".dds") + if Path(dds).exists(): + paths.append(dds) + return paths + + +class StalkerAnomalySaveGameInfoWidget(mobase.ISaveGameInfoWidget): + def __init__(self, parent: QWidget | None): + super().__init__(parent) + layout = QVBoxLayout() + self._labelSave = self.newLabel(layout) + self._labelName = self.newLabel(layout) + self._labelFaction = self.newLabel(layout) + self._labelHealth = self.newLabel(layout) + self._labelMoney = self.newLabel(layout) + self._labelRank = self.newLabel(layout) + self._labelRep = self.newLabel(layout) + self.setLayout(layout) + palette = self.palette() + palette.setColor(self.backgroundRole(), Qt.GlobalColor.black) + self.setAutoFillBackground(True) + self.setPalette(palette) + self.setWindowFlags( + Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget + ) + + def newLabel(self, layout: QVBoxLayout) -> QLabel: + label = QLabel() + label.setAlignment(Qt.AlignmentFlag.AlignLeft) + palette = label.palette() + palette.setColor(label.foregroundRole(), Qt.GlobalColor.white) + label.setPalette(palette) + layout.addWidget(label) + layout.addStretch() + return label + + def setSave(self, save: mobase.ISaveGame): + self.resize(240, 32) + if not isinstance(save, StalkerAnomalySaveGame): + return + xr_save = save.xr_save + player = xr_save.player + if player: + self._labelSave.setText(f"Save: {xr_save.save_fmt}") + self._labelName.setText(f"Name: {player.character_name_str}") + self._labelFaction.setText(f"Faction: {xr_save.getFaction()}") + self._labelHealth.setText(f"Health: {player.health:.2f}%") + self._labelMoney.setText(f"Money: {player.money} RU") + self._labelRank.setText(f"Rank: {xr_save.getRank()} ({player.rank})") + self._labelRep.setText( + f"Reputation: {xr_save.getReputation()} ({player.reputation})" + ) + + +class StalkerAnomalySaveGameInfo(BasicGameSaveGameInfo): + def getSaveGameWidget(self, parent: QWidget | None = None): + return StalkerAnomalySaveGameInfoWidget(parent) + + +class StalkerAnomalyGame(BasicGame, mobase.IPluginFileMapper): + Name = "STALKER Anomaly" + Author = "Qudix" + Version = "0.5.0" + Description = "Adds support for STALKER Anomaly" + + GameName = "STALKER Anomaly" + GameShortName = "stalkeranomaly" + GameNexusName = "stalkeranomaly" + GameNexusId = 3743 + GameBinary = "AnomalyLauncher.exe" + GameDataPath = "" + GameDocumentsDirectory = "%GAME_PATH%/appdata" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-S.T.A.L.K.E.R.-Anomaly" + ) + + GameSaveExtension = "scop" + GameSavesDirectory = "%GAME_DOCUMENTS%/savedgames" + + def __init__(self): + BasicGame.__init__(self) + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer): + BasicGame.init(self, organizer) + self._register_feature(StalkerAnomalyModDataChecker()) + self._register_feature(StalkerAnomalyModDataContent()) + self._register_feature(StalkerAnomalySaveGameInfo()) + organizer.onAboutToRun(lambda _str: self.aboutToRun(_str)) + return True + + def aboutToRun(self, _str: str) -> bool: + gamedir = self.gameDirectory() + if gamedir.exists(): + # For mappings + gamedir.mkdir("appdata") + # The game will crash if this file exists in the + # virtual tree rather than the game dir + dbg_path = Path(self._gamePath, "gamedata/configs/cache_dbg.ltx") + if not dbg_path.exists(): + dbg_path.parent.mkdir(parents=True, exist_ok=True) + with open(dbg_path, "w", encoding="utf-8"): + pass + return True + + def executables(self) -> list[mobase.ExecutableInfo]: + info = [ + ["Anomaly Launcher", "AnomalyLauncher.exe"], + ["Anomaly (DX11-AVX)", "bin/AnomalyDX11AVX.exe"], + ["Anomaly (DX11)", "bin/AnomalyDX11.exe"], + ["Anomaly (DX10-AVX)", "bin/AnomalyDX10AVX.exe"], + ["Anomaly (DX10)", "bin/AnomalyDX10.exe"], + ["Anomaly (DX9-AVX)", "bin/AnomalyDX9AVX.exe"], + ["Anomaly (DX9)", "bin/AnomalyDX9.exe"], + ["Anomaly (DX8-AVX)", "bin/AnomalyDX8AVX.exe"], + ["Anomaly (DX8)", "bin/AnomalyDX8.exe"], + ] + gamedir = self.gameDirectory() + return [ + mobase.ExecutableInfo(inf[0], QFileInfo(gamedir, inf[1])) for inf in info + ] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + StalkerAnomalySaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] + + def mappings(self) -> list[mobase.Mapping]: + appdata = self.gameDirectory().filePath("appdata") + m = mobase.Mapping() + m.createTarget = True + m.isDirectory = True + m.source = appdata + m.destination = appdata + return [m] diff --git a/libs/basic_games/games/game_stardewvalley.py b/libs/basic_games/games/game_stardewvalley.py new file mode 100644 index 0000000..6397b40 --- /dev/null +++ b/libs/basic_games/games/game_stardewvalley.py @@ -0,0 +1,57 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class StardewValleyModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for e in filetree: + if isinstance(e, mobase.IFileTree) and e.exists( + "manifest.json", mobase.IFileTree.FILE + ): + return mobase.ModDataChecker.VALID + + return mobase.ModDataChecker.INVALID + + +class StardewValleyGame(BasicGame): + Name = "Stardew Valley Support Plugin" + Author = "Syer10" + Version = "0.1.0a" + + GameName = "Stardew Valley" + GameShortName = "stardewvalley" + GameNexusName = "stardewvalley" + GameNexusId = 1303 + GameSteamId = 413150 + GameGogId = 1453375253 + GameBinary = "Stardew Valley.exe" + GameDataPath = "mods" + GameDocumentsDirectory = "%DOCUMENTS%/StardewValley" + GameSavesDirectory = "%GAME_DOCUMENTS%/Saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Stardew-Valley" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(StardewValleyModDataChecker()) + return True + + def executables(self): + return [ + mobase.ExecutableInfo( + "SMAPI", QFileInfo(self.gameDirectory(), "StardewModdingAPI.exe") + ), + mobase.ExecutableInfo( + "Stardew Valley", QFileInfo(self.gameDirectory(), "Stardew Valley.exe") + ), + ] diff --git a/libs/basic_games/games/game_starsector.py b/libs/basic_games/games/game_starsector.py new file mode 100644 index 0000000..2dc27d9 --- /dev/null +++ b/libs/basic_games/games/game_starsector.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_game import BasicGame, BasicGameSaveGame + + +class Starsector(BasicGame): + Name = "Starsector Support Plugin" + Author = "ddbb07" + Version = "1.0.1" + + GameName = "Starsector" + GameShortName = "starsector" + GameNexusName = "starsector" + GameBinary = "starsector.exe" + GameDataPath = "mods" + GameSavesDirectory = "%GAME_PATH%/saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Starsector" + ) + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + return [ + BasicGameSaveGame(path) + for path in Path(folder.absolutePath()).glob("save_*") + ] diff --git a/libs/basic_games/games/game_starwars-empire-at-war-foc.py b/libs/basic_games/games/game_starwars-empire-at-war-foc.py new file mode 100644 index 0000000..188c288 --- /dev/null +++ b/libs/basic_games/games/game_starwars-empire-at-war-foc.py @@ -0,0 +1,33 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class StarWarsEmpireAtWarGame(BasicGame): + Name = "STAR WARS Empire at War - Force of Corruption" + Author = "erri120" + Version = "1.0.0" + + GameName = "STAR WARS™ Empire at War: Forces of Corruption" + GameShortName = "starwarsempireatwar" + GameNexusName = "starwarsempireatwar" + GameNexusId = 453 + GameSteamId = 32470 + GameGogId = 1421404887 + # using StarWarsG.exe instead of swfoc.exe because it has an icon + GameBinary = "corruption/StarWarsG.exe" + GameDataPath = "corruption/Data" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Star-Wars:-Empire-At-War" + ) + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + "STAR WARS Empire at War: Forces of Corruption", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ] diff --git a/libs/basic_games/games/game_starwars-empire-at-war.py b/libs/basic_games/games/game_starwars-empire-at-war.py new file mode 100644 index 0000000..171ecfc --- /dev/null +++ b/libs/basic_games/games/game_starwars-empire-at-war.py @@ -0,0 +1,33 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class StarWarsEmpireAtWarGame(BasicGame): + Name = "STAR WARS Empire at War" + Author = "erri120" + Version = "1.0.0" + + GameName = "STAR WARS™ Empire at War" + GameShortName = "starwarsempireatwar" + GameNexusName = "starwarsempireatwar" + GameNexusId = 453 + GameSteamId = 32470 + GameGogId = 1421404887 + # using StarWarsG.exe instead of sweaw.exe because it has an icon + GameBinary = "GameData/StarWarsG.exe" + GameDataPath = "GameData/Data" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Star-Wars:-Empire-At-War" + ) + + def executables(self) -> list[mobase.ExecutableInfo]: + return [ + mobase.ExecutableInfo( + "STAR WARS Empire at War", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ] diff --git a/libs/basic_games/games/game_subnautica-below-zero.py b/libs/basic_games/games/game_subnautica-below-zero.py new file mode 100644 index 0000000..297049f --- /dev/null +++ b/libs/basic_games/games/game_subnautica-below-zero.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from ..basic_features import GlobPatterns +from . import game_subnautica # namespace to not load SubnauticaGame here, too! + + +class SubnauticaBelowZeroGame(game_subnautica.SubnauticaGame): + Name = "Subnautica Below Zero Support Plugin" + + GameName = "Subnautica: Below Zero" + GameShortName = "subnauticabelowzero" + GameNexusName = "subnauticabelowzero" + GameThunderstoreName = "subnautica-below-zero" + GameSteamId = 848450 + GameEpicId = "foxglove" + GameBinary = "SubnauticaZero.exe" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Subnautica:-Below-Zero" + ) + + _game_extra_save_paths = [ + r"%USERPROFILE%\Appdata\LocalLow\Unknown Worlds" + r"\Subnautica Below Zero\SubnauticaZero\SavedGames" + ] + + def _set_mod_data_checker( + self, extra_patterns: GlobPatterns | None = None, use_qmod: bool | None = None + ): + super()._set_mod_data_checker( + GlobPatterns(unfold=["BepInExPack_BelowZero"]), use_qmod + ) diff --git a/libs/basic_games/games/game_subnautica.py b/libs/basic_games/games/game_subnautica.py new file mode 100644 index 0000000..f9e5128 --- /dev/null +++ b/libs/basic_games/games/game_subnautica.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import fnmatch +import os +from collections.abc import Iterable +from enum import Enum +from pathlib import Path + +from PyQt6.QtCore import QDir, qWarning + +import mobase + +from ..basic_features import BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) +from ..basic_features.utils import is_directory +from ..basic_game import BasicGame + + +class SubnauticaModDataChecker(BasicModDataChecker): + use_qmods: bool = False + + def __init__(self, patterns: GlobPatterns | None = None, use_qmods: bool = False): + super().__init__( + GlobPatterns( + unfold=["BepInExPack_Subnautica"], + valid=[ + "BepInEx", + "doorstop_libs", + "doorstop_config.ini", + "run_bepinex.sh", + "winhttp.dll", + "QMods", + ".doorstop_version", # Added in Tobey's BepInEx Pack for Subnautica v5.4.23 + "changelog.txt", + "libdoorstop.dylib", + ], + ignore=["*.mohidden"], + delete=[ + "*.txt", + "*.md", + "icon.png", + "license", + "manifest.json", + ], + move={ + "plugins": "BepInEx/", + "patchers": "BepInEx/", + "CustomCraft2SML": "QMods/" if use_qmods else "BepInEx/plugins/", + "CustomCraft3": "QMods/" if use_qmods else "BepInEx/plugins/", + }, + ).merge(patterns or GlobPatterns()), + ) + self.use_qmods = use_qmods + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # fix: single root folders get traversed by Simple Installer + parent = filetree.parent() + if parent is not None and self.dataLooksValid(parent) is self.FIXABLE: + return self.FIXABLE + check_return = super().dataLooksValid(filetree) + # A single unknown folder with a dll file in is to be moved to BepInEx/plugins/ + if ( + check_return is self.INVALID + and len(filetree) == 1 + and is_directory(folder := filetree[0]) + and any(fnmatch.fnmatch(entry.name(), "*.dll") for entry in folder) + ): + return self.FIXABLE + return check_return + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + filetree = super().fix(filetree) + if ( + self.dataLooksValid(filetree) is self.FIXABLE + and len(filetree) == 1 + and is_directory(folder := filetree[0]) + and any(fnmatch.fnmatch(entry.name(), "*.dll") for entry in folder) + ): + filetree.move(folder, "QMods/" if self.use_qmods else "BepInEx/plugins/") + return filetree + + +class SubnauticaGame(BasicGame, mobase.IPluginFileMapper): + Name = "Subnautica Support Plugin" + Author = "dekart811, Zash" + Version = "2.3" + + GameName = "Subnautica" + GameShortName = "subnautica" + GameNexusName = "subnautica" + GameThunderstoreName = "subnautica" + GameSteamId = 264710 + GameEpicId = "Jaguar" + GameBinary = "Subnautica.exe" + GameDataPath = "_ROOT" # Custom mappings to actual root folders below. + GameDocumentsDirectory = r"%GAME_PATH%" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Subnautica" + ) + GameSavesDirectory = r"%GAME_PATH%\SNAppData\SavedGames" + + _game_extra_save_paths = [ + r"%USERPROFILE%\Appdata\LocalLow\Unknown Worlds" + r"\Subnautica\Subnautica\SavedGames" + ] + + _forced_libraries = ["winhttp.dll"] + + _root_blacklist = {GameDataPath.casefold()} + + class MapType(Enum): + FILE = 0 + FOLDER = 1 + + _root_extra_overwrites: dict[str, MapType] = { + "qmodmanager_log-Subnautica.txt": MapType.FILE, + "qmodmanager-config.json": MapType.FILE, + "BepInEx_Shim_Backup": MapType.FOLDER, + } + """Extra files & folders created in game root by mods / BepInEx after game launch, + but not included in the mod archives. + """ + + def __init__(self): + super().__init__() + mobase.IPluginFileMapper.__init__(self) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._set_mod_data_checker() + self._register_feature( + BasicGameSaveGameInfo(lambda s: Path(s or "", "screenshot.jpg")) + ) + + organizer.onPluginSettingChanged(self._settings_change_callback) + return True + + def _set_mod_data_checker( + self, extra_patterns: GlobPatterns | None = None, use_qmod: bool | None = None + ): + self._register_feature( + SubnauticaModDataChecker( + patterns=(GlobPatterns() if extra_patterns is None else extra_patterns), + use_qmods=( + bool(self._organizer.pluginSetting(self.name(), "use_qmods")) + if use_qmod is None + else use_qmod + ), + ) + ) + + def _settings_change_callback( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ): + if plugin_name == self.name() and setting == "use_qmods": + self._set_mod_data_checker(use_qmod=bool(new)) + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "use_qmods", + ( + "Install */.dll mods in legacy QMods folder," + " instead of BepInEx/plugins (default)." + ), + default_value=False, + ) + ] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + return [ + BasicGameSaveGame(folder) + for save_path in ( + folder.absolutePath(), + *(os.path.expandvars(p) for p in self._game_extra_save_paths), + ) + for folder in Path(save_path).glob("slot*") + ] + + def executables(self) -> list[mobase.ExecutableInfo]: + binary = self.gameDirectory().absoluteFilePath(self.binaryName()) + return [ + mobase.ExecutableInfo( + self.gameName(), + binary, + ).withArgument("-vrmode none"), + mobase.ExecutableInfo( + f"{self.gameName()} VR", + self.gameDirectory().absoluteFilePath(self.binaryName()), + ), + ] + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + return [ + mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True) + for lib in self._forced_libraries + ] + + def mappings(self) -> list[mobase.Mapping]: + game = self._organizer.managedGame() + game_path = Path(game.gameDirectory().absolutePath()) + overwrite_path = Path(self._organizer.overwritePath()) + + return [ + *( + # Extra overwrites + self._overwrite_mapping( + overwrite_path / name, + dest, + is_dir=(map_type is self.MapType.FOLDER), + ) + for name, map_type in self._root_extra_overwrites.items() + if not (dest := game_path / name).exists() + ), + *self._root_mappings(game_path, overwrite_path), + ] + + def _root_mappings( + self, game_path: Path, overwrite_path: Path + ) -> Iterable[mobase.Mapping]: + for mod_path in self._active_mod_paths(): + mod_name = mod_path.name + + for child in mod_path.iterdir(): + # Check blacklist + if child.name.casefold() in self._root_blacklist: + qWarning(f"Skipping {child.name} ({mod_name})") + continue + destination = game_path / child.name + # Check existing + if destination.exists(): + qWarning( + f"Overwriting of existing game files/folders is not supported! " + f"{destination.as_posix()} ({mod_name})" + ) + continue + # Mapping: mod -> root + yield mobase.Mapping( + source=str(child), + destination=str(destination), + is_directory=child.is_dir(), + create_target=False, + ) + if child.is_dir(): + # Mapping: overwrite <-> root + yield self._overwrite_mapping( + overwrite_path / child.name, destination, is_dir=True + ) + + def _active_mod_paths(self) -> Iterable[Path]: + mods_parent_path = Path(self._organizer.modsPath()) + modlist = self._organizer.modList().allModsByProfilePriority() + for mod in modlist: + if self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: + yield mods_parent_path / mod + + def _overwrite_mapping( + self, overwrite_source: Path, destination: Path, is_dir: bool + ) -> mobase.Mapping: + """Mapping: overwrite <-> root""" + if is_dir: + # Root folders in overwrite need to exits. + overwrite_source.mkdir(parents=True, exist_ok=True) + return mobase.Mapping( + str(overwrite_source), + str(destination), + is_dir, + create_target=True, + ) diff --git a/libs/basic_games/games/game_tdu.py b/libs/basic_games/games/game_tdu.py new file mode 100644 index 0000000..5624ff8 --- /dev/null +++ b/libs/basic_games/games/game_tdu.py @@ -0,0 +1,36 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class TDUGame(BasicGame): + Name = "Test Drive Unlimited Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Test Drive Unlimited" + GameShortName = "tdu" + GameNexusName = "testdriveunlimited" + GameNexusId = 4615 + GameBinary = "TestDriveUnlimited.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Test Drive Unlimited", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ).withArgument("-w -wx -vsync -bigbnks -offline"), + mobase.ExecutableInfo( + "Project Paradise Launcher", + QFileInfo( + self.gameDirectory().absoluteFilePath( + "TDU - Project Paradise Launcher.exe" + ) + ), + ), + ] diff --git a/libs/basic_games/games/game_tdu2.py b/libs/basic_games/games/game_tdu2.py new file mode 100644 index 0000000..f7fd94f --- /dev/null +++ b/libs/basic_games/games/game_tdu2.py @@ -0,0 +1,35 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class TDU2Game(BasicGame): + Name = "Test Drive Unlimited 2 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Test Drive Unlimited 2" + GameShortName = "tdu2" + GameNexusName = "testdriveunlimited2" + GameNexusId = 2353 + GameSteamId = 9930 + GameBinary = "UpLauncher.exe" + GameDataPath = "" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Test Drive Unlimited 2", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "TDU2 Universal Launcher", + QFileInfo( + self.gameDirectory().absoluteFilePath("TDU2_Universal_Launcher.exe") + ), + ), + ] diff --git a/libs/basic_games/games/game_thebindingofisaacrebirth.py b/libs/basic_games/games/game_thebindingofisaacrebirth.py new file mode 100644 index 0000000..a21f11d --- /dev/null +++ b/libs/basic_games/games/game_thebindingofisaacrebirth.py @@ -0,0 +1,23 @@ +from ..basic_game import BasicGame + + +class TheBindingOfIsaacRebirthGame(BasicGame): + Name = "The Binding of Isaac: Rebirth - Support Plugin" + Author = "Luca/EzioTheDeadPoet" + Version = "0.1.0" + + GameName = "The Binding of Isaac: Rebirth" + GameShortName = "thebindingofisaacrebirth" + GameNexusName = "thebindingofisaacrebirth" + GameNexusId = 1293 + GameSteamId = 250900 + GameBinary = "isaac-ng.exe" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/Binding of Isaac Afterbirth+" + GameDataPath = "%DOCUMENTS%/My Games/Binding of Isaac Afterbirth+ Mods" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-The-Binding-of-Isaac:-Rebirth" + ) + + def iniFiles(self): + return ["options.ini"] diff --git a/libs/basic_games/games/game_thps3.py b/libs/basic_games/games/game_thps3.py new file mode 100644 index 0000000..52ea07c --- /dev/null +++ b/libs/basic_games/games/game_thps3.py @@ -0,0 +1,38 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS3Game(BasicGame): + Name = "Tony Hawk's Pro Skater 3 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Pro Skater 3" + GameShortName = "thps3" + GameBinary = "Skate3.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 3", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 3 Setup", + QFileInfo(self.gameDirectory().absoluteFilePath("THPS3Setup.exe")), + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 3 (PARTYMOD)", + QFileInfo(self.gameDirectory().absoluteFilePath("THPS3.exe")), + ), + mobase.ExecutableInfo( + "PARTYMOD Configurator", + QFileInfo(self.gameDirectory().absoluteFilePath("partyconfig.exe")), + ), + ] diff --git a/libs/basic_games/games/game_thps4.py b/libs/basic_games/games/game_thps4.py new file mode 100644 index 0000000..7a7ca0b --- /dev/null +++ b/libs/basic_games/games/game_thps4.py @@ -0,0 +1,40 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS4Game(BasicGame): + Name = "Tony Hawk's Pro Skater 4 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Pro Skater 4" + GameShortName = "thps4" + GameBinary = "Skate4.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 4", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 4 Setup", + QFileInfo(self.gameDirectory().absoluteFilePath("../Start.exe")), + ).withWorkingDirectory( + QDir(QDir.cleanPath(self.gameDirectory().absoluteFilePath(".."))) + ), + mobase.ExecutableInfo( + "Tony Hawk's Pro Skater 4 (PARTYMOD)", + QFileInfo(self.gameDirectory().absoluteFilePath("THPS4.exe")), + ), + mobase.ExecutableInfo( + "PARTYMOD Configurator", + QFileInfo(self.gameDirectory().absoluteFilePath("partyconfig.exe")), + ), + ] diff --git a/libs/basic_games/games/game_thug.py b/libs/basic_games/games/game_thug.py new file mode 100644 index 0000000..6a0976f --- /dev/null +++ b/libs/basic_games/games/game_thug.py @@ -0,0 +1,36 @@ +# -*- encoding: utf-8 -*- + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS4Game(BasicGame): + Name = "Tony Hawk's Underground Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Underground" + GameShortName = "thug" + GameBinary = "THUG.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Underground", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground Launcher", + QFileInfo(self.gameDirectory().absoluteFilePath("../Launcher.exe")), + ).withWorkingDirectory( + QDir(QDir.cleanPath(self.gameDirectory().absoluteFilePath(".."))) + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground (ClownJob'd)", + QFileInfo(self.gameDirectory().absoluteFilePath("THUGONE.exe")), + ), + ] diff --git a/libs/basic_games/games/game_thug2.py b/libs/basic_games/games/game_thug2.py new file mode 100644 index 0000000..389e204 --- /dev/null +++ b/libs/basic_games/games/game_thug2.py @@ -0,0 +1,54 @@ +# -*- encoding: utf-8 -*- + +import os + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class THPS4Game(BasicGame): + Name = "Tony Hawk's Underground 2 Support Plugin" + Author = "uwx" + Version = "1.0.0" + + GameName = "Tony Hawk's Underground 2" + GameShortName = "thug2" + GameBinary = "THUG2.exe" + GameDataPath = "Data" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Tony Hawk's Underground 2", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground 2 Launcher", + QFileInfo(self.gameDirectory().absoluteFilePath("../Launcher.exe")), + ).withWorkingDirectory( + QDir(QDir.cleanPath(self.gameDirectory().absoluteFilePath(".."))) + ), + mobase.ExecutableInfo( + "Tony Hawk's Underground 2 (ClownJob'd)", + QFileInfo(self.gameDirectory().absoluteFilePath("THUGTWO.exe")), + ), + mobase.ExecutableInfo( + "THUG Pro Launcher", + QFileInfo( + QDir(os.getenv("LOCALAPPDATA")).absoluteFilePath( + "THUG Pro/THUGProLauncher.exe" + ) + ), + ), + mobase.ExecutableInfo( + "THUG Pro", + QFileInfo( + QDir(os.getenv("LOCALAPPDATA")).absoluteFilePath( + "THUG Pro/THUGPro.exe" + ) + ), + ), + ] diff --git a/libs/basic_games/games/game_tmuf.py b/libs/basic_games/games/game_tmuf.py new file mode 100644 index 0000000..648da6d --- /dev/null +++ b/libs/basic_games/games/game_tmuf.py @@ -0,0 +1,35 @@ +# -*- encoding: utf-8 -*- + +from __future__ import annotations + +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class TmufGame(BasicGame): + Name = "Trackmania United Forever Support Plugin" + Author = "uwx" + Version = "1.0.0" + Description = "Adds support for Trackmania United Forever game folder mods." + + GameName = "Trackmania United Forever" + GameShortName = "tmuf" + GameNexusName = "trackmaniaunited" + GameNexusId = 1500 + GameSteamId = 7200 + GameBinary = "TmForeverLauncher.exe" + GameDataPath = "GameData" + + def executables(self): + return [ + mobase.ExecutableInfo( + "Trackmania United Forever", + QFileInfo( + self.gameDirectory(), + self.binaryName(), + ), + ), + ] diff --git a/libs/basic_games/games/game_trainsimulator.py b/libs/basic_games/games/game_trainsimulator.py new file mode 100644 index 0000000..dc1d44e --- /dev/null +++ b/libs/basic_games/games/game_trainsimulator.py @@ -0,0 +1,36 @@ +from PyQt6.QtCore import QFileInfo + +import mobase + +from ..basic_game import BasicGame + + +class RailworksGame(BasicGame): + Name = "Train Simulator Classic Support Plugin" + Author = "Ryan Young" + Version = "1.1.0" + + GameName = "Train Simulator" + GameShortName = "railworks" + GameBinary = "RailWorks.exe" + GameDataPath = "" + GameSteamId = "24010" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Train-Simulator-Classic" + ) + + def executables(self): + game_directory = self.gameDirectory() + executables: list[tuple[str, str]] = [ + ("32-bit", "RailWorks.exe"), + ("64-bit", "RailWorks64.exe"), + ("64-bit, DirectX 12", "RailWorksDX12_64.exe"), + ] + return [ + mobase.ExecutableInfo( + f"Train Simulator ({name})", + QFileInfo(game_directory.absoluteFilePath(path)), + ) + for name, path in executables + ] diff --git a/libs/basic_games/games/game_valheim.py b/libs/basic_games/games/game_valheim.py new file mode 100644 index 0000000..c402323 --- /dev/null +++ b/libs/basic_games/games/game_valheim.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import itertools +import re +import shutil +from collections.abc import Collection, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional, TextIO + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +def move_file(source: Path, target: Path): + """Move `source` to `target`. Creates missing (parent) directories and + overwrites existing `target`.""" + if not target.parent.exists(): + target.parent.mkdir(parents=True) + shutil.move(str(source.resolve()), str(target.resolve())) + + +@dataclass +class PartialMatch: + partial_match_regex: re.Pattern[str] = re.compile(r"[A-Z]?[a-z]+") + """Matches words, for e.g. 'Camel' and 'Case' in 'CamelCase'.""" + + exclude: set[str] = field(default_factory=set[str]) + min_length: int = 3 + + def partial_match(self, str_with_parts: str, search_string: str) -> Collection[str]: + """Returns partial matches of the first string (`str_with_parts`) + in the `search_string`. + + See: + `partial_match_regex` + """ + parts = self.partial_match_regex.finditer(str_with_parts) + search_string_lower = search_string.casefold() + return set( + p_lower + for p in parts + if len(p_lower := p[0].casefold()) >= self.min_length + and p_lower not in self.exclude + and p_lower in search_string_lower + ) + + +@dataclass +class ContentMatch: + file_glob_patterns: Iterable[str] + """File patterns (glob) for content files and `content_regex`.""" + content_regex: re.Pattern[str] + """Regex to get mod name from content. with (?P<`match_group`>) group.""" + match_group: str + + def match_content(self, file_path: Path) -> str: + if ( + self.content_regex + and self.file_glob_patterns + and any(file_path.match(p) for p in self.file_glob_patterns) + ): + with open(file_path) as file: + if match := self.content_regex.search(file.read()): + return match.group(self.match_group) + return "" + + +class DebugTable: + """Debug in markdown table.""" + + def __init__(self, column_keys: Collection[str]) -> None: + """Init the table, adds the header. + + Args: + column_keys: The column keys for the table. + """ + self.new_table(column_keys) + + def __call__(self, **kwargs: Any) -> None: + self.add(**kwargs) + + def new_table(self, column_keys: Collection[str] | None = None) -> None: + if column_keys: + self._column_keys = column_keys + self._table: list[dict[str, str]] = [ + {c: c for c in self._column_keys}, + {c: "-" * len(c) for c in self._column_keys}, + ] + + def add( + self, + **kwargs: Any, + ) -> None: + """Add data to the table. Adds a new line if the last row has already data in + for the column key. + + Args: + **kwargs: the values per column key for a row. + """ + for k, v in kwargs.items(): + if not self._table or self._table[-1].get(k, ""): + # Append line if element in last list is set. + self._table.append(dict.fromkeys(self._column_keys, "")) + self._table[-1][k] = str(v) + + def print(self, output_file: Optional[TextIO] = None): + if self._table: + for line in self._table: + print("|", " | ".join(line.values()), "|", file=output_file) + if output_file: + output_file.flush() + self._table = [] + + +class OverwriteSync: + organizer: mobase.IOrganizer + game: mobase.IPluginGame + search_file_contents: bool = True + + overwrite_file_pattern: Iterable[str] = ["BepInEx/config/*"] + """File pattern (glob) in overwrite folder.""" + partial_match: PartialMatch = PartialMatch(exclude={"valheim", "mod"}) + content_match: ContentMatch = ContentMatch( + file_glob_patterns=["*.cfg"], + content_regex=re.compile(r"\A.*plugin (?P<mod>.+) v[\d.]+?$", re.I | re.M), + match_group="mod", + ) + + _debug = DebugTable("overwrite_file | mod | target_path | matches".split(" | ")) + + def __init__(self, organizer: mobase.IOrganizer, game: mobase.IPluginGame) -> None: + self.organizer = organizer + self.game = game + + def sync(self) -> None: + """Sync the Overwrite folder (back) to the mods.""" + print("Syncing Overwrite with mods") + modlist = self.organizer.modList() + mod_map = self._get_active_mods(modlist) + mod_dll_map = self._get_mod_dll_map(mod_map) + overwrite_path = Path(self.organizer.overwritePath()) + self._debug.new_table() + for pattern in self.overwrite_file_pattern: + for file_path in overwrite_path.glob(pattern): + self._debug(overwrite_file=file_path.name) + if mod := self._find_mod_for_overwrite_file(file_path, mod_dll_map): + # Move cfg to mod folder + mod_path = Path(modlist.getMod(mod).absolutePath()) + target_path = mod_path / file_path.relative_to(overwrite_path) + self._debug(mod=mod, target_path=target_path) + move_file(file_path, target_path) + self._debug.print() + + def _get_active_mods( + self, modlist: mobase.IModList | None = None + ) -> dict[str, mobase.IModInterface]: + """Get all active mods. + + Args: modlist (optional): the `mobase.IModList`. Defaults to None (get it from + `self._organizer`). + + Returns: `{mod_name: mobase.IModInterface}` + """ + modlist = modlist or self.organizer.modList() + + return { + name: mod + for name in modlist.allMods() # allModsByProfilePriority ? + if (mod := modlist.getMod(name)).gameName() == self.game.gameShortName() + and not mod.isForeign() + and not mod.isBackup() + and not mod.isSeparator() + and (modlist.state(name) & mobase.ModState.ACTIVE) + } + + def _get_mod_dll_map(self, mod_map: Mapping[str, str | mobase.IModInterface]): + return {name: self._get_mod_dlls(mod) for name, mod in mod_map.items()} + + def _get_mod_dlls(self, mod: str | mobase.IModInterface) -> Sequence[str]: + """Get all BepInEx/plugins/*.dll files of a mod.""" + if isinstance(mod, str): + mod = self.organizer.modList().getMod(mod) + plugins = mod.fileTree().find("BepInEx/plugins/", mobase.IFileTree.DIRECTORY) + if isinstance(plugins, mobase.IFileTree): + return [name for p in plugins if (name := p.name()).endswith(".dll")] + else: + return [] + + def _find_mod_for_overwrite_file( + self, + file_path: Path, + mod_dll_map: Mapping[str, Collection[str]], + ) -> str: + """Find the mod (name) matching a file in Overwrite (using the mods dll name). + + Args: + file_path: The name of the file. + mod_dll_map: Mods names and their dll files `{mod_name: ["ModName.dll"]}`. + + Returns: + The name of the mod matching the given file_name best. + If there is no clear mod match for the file, an empty string "" is returned. + """ + if file_path.is_dir(): + return "" + file_name = file_path.stem + # matching metric: combined length of partial matches per mod. + matching_mods = self._get_matching_mods(file_name, mod_dll_map) + if len(matching_mods) == 0: + if self.search_file_contents and self.content_match: + # Get mod name from file content. + long_mod_name = self.content_match.match_content(file_path) + matching_mods = self._get_matching_mods(long_mod_name, mod_dll_map) + if len(matching_mods) == 1: + # Only a single mod match found. + return matching_mods[0][0] + elif len(matching_mods) > 1: + # Find mod with longest (combined) partial_match. + self._debug.add(matches=matching_mods) + # Do not return a mod for multiple "equal" matches. + if matching_mods[0][1] > matching_mods[1][1]: + return matching_mods[0][0] + return "" + + def _get_matching_mods( + self, search_str: str, mod_dll_map: Mapping[str, Collection[str]] + ) -> Sequence[tuple[str, int, set[str]]]: + """Find matching mods for the given `search_str`. + + Args: + search_str: A string to find a mod match for. + mod_dll_map: Mods names and their dll files `{mod_name: ["ModName.dll"]}`. + + Returns: + Mods with partial matches, sorted descending by their metric + (length of combined partial matches): + {mod_name: (len_of_combined_partial_matches, {partial_matches, ...}), ...} + """ + return sorted( + ( + (name, sum(len(s) for s in partial_matches), partial_matches) + for name, dlls in mod_dll_map.items() + if len( + partial_matches := set( + itertools.chain.from_iterable( + self.partial_match.partial_match(search_str, dll) + for dll in dlls + ) + ) + ) + > 0 + ), + key=lambda x: x[1], + reverse=True, + ) + + +class ValheimSaveGame(BasicGameSaveGame): + def getName(self) -> str: + return f"[{self.getSaveGroupIdentifier().rstrip('s')}] {self._filepath.stem}" + + def getSaveGroupIdentifier(self) -> str: + return self._filepath.parent.name + + def allFiles(self) -> list[str]: + files = super().allFiles() + files.extend( + self._filepath.with_suffix(suffix).as_posix() + for suffix in [self._filepath.suffix + ".old"] + ) + return files + + +class ValheimWorldSaveGame(ValheimSaveGame): + def allFiles(self) -> list[str]: + files = super().allFiles() + files.extend( + self._filepath.with_suffix(suffix).as_posix() + for suffix in [".db", ".db.old"] + ) + return files + + +class ValheimGame(BasicGame): + Name = "Valheim Support Plugin" + Author = "Zash" + Version = "1.3" + + GameName = "Valheim" + GameShortName = "valheim" + GameThunderstoreName = "valheim" + GameNexusId = 3667 + GameSteamId = [892970, 896660, 1223920] + GameBinary = "valheim.exe" + GameDataPath = "" + GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/IronGate/Valheim" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/Game:-Valheim" + ) + + _forced_libraries = ["winhttp.dll"] + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature( + BasicModDataChecker( + GlobPatterns( + unfold=[ + "BepInExPack_Valheim", + ], + valid=[ + "meta.ini", # Included in installed mod folder. + "BepInEx", + "doorstop_libs", + "unstripped_corlib", + ".doorstop_version", + "doorstop_config.ini", + "start_game_bepinex.sh", + "start_server_bepinex.sh", + "winhttp.dll", + "changelog.txt", + # + "InSlimVML", + "valheim_Data", + "inslimvml.ini", + # + "unstripped_managed", + # + "AdvancedBuilder", + ], + ignore=[ + "*.mohidden", + ], + delete=[ + "*.txt", + "*.md", + "README", + "icon.png", + "license", + "manifest.json", + "*.dll.mdb", + "*.pdb", + ], + move={ + "*_VML.dll": "InSlimVML/Mods/", + # + "plugins": "BepInEx/", + "Jotunn": "BepInEx/plugins/", + "*.dll": "BepInEx/plugins/", + "*.xml": "BepInEx/plugins/", + "Translations": "BepInEx/plugins/", + "config": "BepInEx/", + "*.cfg": "BepInEx/config/", + # + "CustomTextures": "BepInEx/plugins/", + "*.png": "BepInEx/plugins/CustomTextures/", + # + "Builds": "AdvancedBuilder/", + "*.vbuild": "AdvancedBuilder/Builds/", + # + "*.assets": "valheim_Data/", + }, + ) + ) + ) + self._register_feature(BasicLocalSavegames(self)) + self._overwrite_sync = OverwriteSync(organizer=self._organizer, game=self) + self._register_event_handler() + return True + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + return [ + mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True) + for lib in self._forced_libraries + ] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + save_games = super().listSaves(folder) + path = Path(folder.absolutePath()) + save_games.extend(ValheimSaveGame(f) for f in path.glob("characters/*.fch")) + save_games.extend(ValheimWorldSaveGame(f) for f in path.glob("worlds/*.fwl")) + return save_games + + def settings(self) -> list[mobase.PluginSetting]: + settings = super().settings() + settings.extend( + [ + mobase.PluginSetting( + "sync_overwrite", "Sync overwrite with mods", True + ), + mobase.PluginSetting( + "search_overwrite_file_content", + "Search content of files in overwrite for matching mod", + True, + ), + ] + ) + return settings + + def _register_event_handler(self): + self._organizer.onUserInterfaceInitialized(lambda win: self._sync_overwrite()) + self._organizer.onFinishedRun(self._game_finished_event_handler) + + def _game_finished_event_handler(self, app_path: str, exit_code: int) -> None: + """Sync overwrite folder with mods after game was closed.""" + if Path(app_path) == Path( + self.gameDirectory().absolutePath(), self.binaryName() + ): + self._sync_overwrite() + + def _sync_overwrite(self) -> None: + if self._organizer.managedGame() is not self: + return + if self._organizer.pluginSetting(self.name(), "sync_overwrite") is not False: + self._overwrite_sync.search_file_contents = ( + self._organizer.pluginSetting( + self.name(), "search_overwrite_file_content" + ) + is not False + ) + self._overwrite_sync.sync() diff --git a/libs/basic_games/games/game_valkyriachronicles.py b/libs/basic_games/games/game_valkyriachronicles.py new file mode 100644 index 0000000..d208de6 --- /dev/null +++ b/libs/basic_games/games/game_valkyriachronicles.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class ValkyriaChroniclesGame(BasicGame): + Name = "Valkyria Chronicles Support Plugin" + Author = "Ketsuban" + Version = "1.0.0" + + GameName = "Valkyria Chronicles" + GameShortName = "vc1" + GameBinary = "Valkyria.exe" + GameLauncher = "Launcher.exe" + GameDataPath = "%GAME_PATH%" + GameSavesDirectory = "%GAME_PATH%/savedata" + GameSteamId = 294860 diff --git a/libs/basic_games/games/game_vampirebloodlines.py b/libs/basic_games/games/game_vampirebloodlines.py new file mode 100644 index 0000000..a63b308 --- /dev/null +++ b/libs/basic_games/games/game_vampirebloodlines.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import List + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_game import BasicGame, BasicGameSaveGame + + +class VampireModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + self.validDirNames = [ + "cfg", + "cl_dlls", + "dlg", + "dlls", + "maps", + "materials", + "media", + "models", + "particles", + "python", + "resource", + "scripts", + "sound", + "vdata", + ] + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + for entry in filetree: + if not entry.isDir(): + continue + if entry.name().casefold() in self.validDirNames: + return mobase.ModDataChecker.VALID + return mobase.ModDataChecker.INVALID + + +class VampireSaveGame(BasicGameSaveGame): + _filepath: Path + + def __init__(self, filepath: Path): + super().__init__(filepath) + self._filepath = filepath + self.name = None + self.elapsedTime = None + + +class VampireTheMasqueradeBloodlinesGame(BasicGame): + Name = "Vampire - The Masquerade: Bloodlines Support Plugin" + Author = "John" + Version = "1.0.0" + Description = "Adds support for Vampires: The Masquerade - Bloodlines" + + GameName = "Vampire - The Masquerade: Bloodlines" + GameShortName = "vampirebloodlines" + GameNexusName = "vampirebloodlines" + GameNexusId = 437 + GameSteamId = [2600] + GameGogId = [1207659240] + GameBinary = "vampire.exe" + GameDataPath = "vampire" + GameDocumentsDirectory = "%GAME_PATH%/vampire/cfg" + GameSavesDirectory = "%GAME_PATH%/vampire/SAVE" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Vampire:-The-Masquerade-%E2%80%90-Bloodlines" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._register_feature(VampireModDataChecker()) + self._register_feature(BasicLocalSavegames(self)) + return True + + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): + # Create .cfg files if they don't exist + for iniFile in self.iniFiles(): + iniPath = Path(self.documentsDirectory().absoluteFilePath(iniFile)) + if not iniPath.exists(): + with open(iniPath, "w") as _: + pass + + super().initializeProfile(directory, settings) + + def version(self): + # Don't forget to import mobase! + return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.FINAL) + + def iniFiles(self): + return ["autoexec.cfg", "user.cfg"] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + VampireSaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_witcher1.py b/libs/basic_games/games/game_witcher1.py new file mode 100644 index 0000000..4d76b58 --- /dev/null +++ b/libs/basic_games/games/game_witcher1.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import BinaryIO, List + +from PyQt6.QtCore import QDir, QFileInfo + +import mobase + +from ..basic_game import BasicGame, BasicGameSaveGame + + +class Witcher1SaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + self.areaName: str = "" + self.parseSaveFile(filepath) + + @staticmethod + def readInt(fp: BinaryIO, length: int = 4) -> int: + return int.from_bytes(fp.read(length), "little") + + @staticmethod + def readFixedString(fp: BinaryIO, length: int) -> str: + b: bytes = fp.read(length) + res = b.decode("utf-16") + return res.rstrip("\0") + + def parseSaveFile(self, filepath: Path): + # https://github.com/xoreos/xoreos/blob/82bd991052732ab1f8f75f512b3dfabfcc92ae8f/src/aurora/thewitchersavefile.cpp#L60 + with filepath.open(mode="rb") as fp: + magic = fp.read(4) + if magic != b"RGMH": + raise ValueError("Invalid TheWitcherSave file!") + + version = self.readInt(fp) + if version != 1: + raise ValueError("Invalid TheWitcherSave file!") + + # TODO: get the preview image + # dataOffset = self.readInt(fp, 8) + fp.seek(8, 1) + fp.seek(8, 1) + fp.seek(4 * 4, 1) + + lightningStorm = self.readFixedString(fp, 2048) + if lightningStorm != "Lightning Storm": + raise ValueError('Missing "Lightning Storm"') + + areaName1 = self.readFixedString(fp, 2048) + areaName2 = self.readFixedString(fp, 2048) + + if areaName1 != areaName2: + raise ValueError("Invalid Area Name!") + + self.areaName = areaName1 + + def getName(self) -> str: + return self.areaName + + +class Witcher1Game(BasicGame): + Name = "Witcher 1 Support Plugin" + Author = "erri120" + Version = "1.0.0" + + GameName = "The Witcher: Enhanced Edition" + GameShortName = "witcher" + GameNexusName = "witcher" + GameNexusId = 150 + GameSteamId = 20900 + GameGogId = 1207658924 + GameBinary = "System/witcher.exe" + GameDataPath = "Data" + GameSaveExtension = "TheWitcherSave" + GameDocumentsDirectory = "%DOCUMENTS%/The Witcher" + GameSavesDirectory = "%GAME_DOCUMENTS%/saves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-The-Witcher" + ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + return True + + def executables(self) -> List[mobase.ExecutableInfo]: + path = QFileInfo(self.gameDirectory(), "System/witcher.exe") + return [mobase.ExecutableInfo("The Witcher", path)] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + return [ + Witcher1SaveGame(path) + for path in Path(folder.absolutePath()).glob("*.TheWitcherSave") + ] diff --git a/libs/basic_games/games/game_witcher2.py b/libs/basic_games/games/game_witcher2.py new file mode 100644 index 0000000..d38bb6b --- /dev/null +++ b/libs/basic_games/games/game_witcher2.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import List + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class Witcher2SaveGame(BasicGameSaveGame): + def allFiles(self): + return [ + self._filepath.name, + self._filepath.name.replace(".sav", "_640x360.bmp"), + ] + + +class Witcher2Game(BasicGame): + Name = "Witcher 2 Support Plugin" + Author = "DefinitelyNotSade" + Version = "1.0.0" + + GameName = "The Witcher 2: Assassins of Kings" + GameShortName = "witcher2" + GaneNexusHame = "witcher2" + # GameNexusId = 952 + GameSteamId = 20920 + GameGogId = 1207658930 + GameLauncher = "Launcher.exe" + GameBinary = "bin/witcher2.exe" + GameDataPath = "CookedPC" + GameSaveExtension = "sav" + GameDocumentsDirectory = "%DOCUMENTS%/witcher 2/Config" + GameSavesDirectory = "%GAME_DOCUMENTS%/../gamesaves" + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature( + BasicGameSaveGameInfo( + lambda s: Path(str(s.parent) + "\\" + s.stem + "_640x360").with_suffix( + ".bmp" + ) + ) + ) + return True + + def iniFiles(self): + return [ + "User.ini", + "Rendering.ini", + "Community.ini", + "UserContent.ini", + "DIMapping.ini", + "Input_QWERTY.ini", + "Input_AZERTY.ini", + "Input_QWERTZ.ini", + ] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + Witcher2SaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_witcher3.py b/libs/basic_games/games/game_witcher3.py new file mode 100644 index 0000000..ff64df2 --- /dev/null +++ b/libs/basic_games/games/game_witcher3.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import List + +from PyQt6.QtCore import QDir + +import mobase + +from ..basic_features import BasicGameSaveGameInfo +from ..basic_features.basic_save_game_info import BasicGameSaveGame +from ..basic_game import BasicGame + + +class Witcher3SaveGame(BasicGameSaveGame): + def allFiles(self): + return [self._filepath.name, self._filepath.name.replace(".sav", ".png")] + + +class Witcher3Game(BasicGame): + Name = "Witcher 3 Support Plugin" + Author = "Holt59" + Version = "1.0.0a" + + GameName = "The Witcher 3: Wild Hunt" + GameShortName = "witcher3" + GaneNexusHame = "witcher3" + GameNexusId = 952 + GameSteamId = [499450, 292030] + GameGogId = [1640424747, 1495134320, 1207664663, 1207664643] + GameBinary = "bin/x64/witcher3.exe" + GameDataPath = "Mods" + GameSaveExtension = "sav" + GameDocumentsDirectory = "%DOCUMENTS%/The Witcher 3" + GameSavesDirectory = "%GAME_DOCUMENTS%/gamesaves" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-The-Witcher-3" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(BasicGameSaveGameInfo(lambda s: s.with_suffix(".png"))) + return True + + def iniFiles(self): + return ["user.settings", "input.settings"] + + def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + Witcher3SaveGame(path) + for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/libs/basic_games/games/game_x4.py b/libs/basic_games/games/game_x4.py new file mode 100644 index 0000000..a1a7041 --- /dev/null +++ b/libs/basic_games/games/game_x4.py @@ -0,0 +1,17 @@ +from ..basic_game import BasicGame + + +class X4FoundationsGame(BasicGame): + Name = "X4 Foundations Support Plugin" + Author = "Twinki,BrandonM4" + Version = "0.1.0" + + GameName = "X4: Foundations" + GameShortName = "x4foundations" + + GameDocumentsDirectory = "%DOCUMENTS%/Egosoft/X4" + GameBinary = "x4.exe" + GameDataPath = "extensions" + + GameNexusId = 2659 + GameSteamId = 392160 diff --git a/libs/basic_games/games/game_xplane11.py b/libs/basic_games/games/game_xplane11.py new file mode 100644 index 0000000..ecbe89a --- /dev/null +++ b/libs/basic_games/games/game_xplane11.py @@ -0,0 +1,16 @@ +from ..basic_game import BasicGame + + +class XP11Game(BasicGame): + Name = "X-Plane 11 Support Plugin" + Author = "Deorder" + Version = "0.0.1" + + GameName = "X-Plane 11" + GameShortName = "xp11" + GameBinary = r"X-Plane.exe" + GameDataPath = r"" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-X%E2%80%90Plane-11" + ) diff --git a/libs/basic_games/games/game_zeusandposeidon.py b/libs/basic_games/games/game_zeusandposeidon.py new file mode 100644 index 0000000..33a8c5a --- /dev/null +++ b/libs/basic_games/games/game_zeusandposeidon.py @@ -0,0 +1,70 @@ +from typing import List, Optional + +import mobase + +from ..basic_game import BasicGame + + +class ZeusAndPoseidonModDataChecker(mobase.ModDataChecker): + def __init__(self): + super().__init__() + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + folders: List[mobase.IFileTree] = [] + files: List[mobase.FileTreeEntry] = [] + + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + folders.append(entry) + else: + files.append(entry) + + if len(folders) != 1: + return mobase.ModDataChecker.INVALID + + folder = folders[0] + pakfile = folder.name() + ".pak" + if folder.exists(pakfile): + if filetree.exists(pakfile): + return mobase.ModDataChecker.VALID + else: + return mobase.ModDataChecker.FIXABLE + + return mobase.ModDataChecker.INVALID + + def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: + first_entry = filetree[0] + if not isinstance(first_entry, mobase.IFileTree): + return None + entry = first_entry.find(filetree[0].name() + ".pak") + if entry is None: + return None + filetree.copy(entry, "", mobase.IFileTree.InsertPolicy.FAIL_IF_EXISTS) + return filetree + + +class ZeusAndPoseidonGame(BasicGame): + Name = "Zeus and Poseidon Support Plugin" + Author = "Holt59" + Version = "1.0.0a" + + GameName = "Zeus and Poseidon" + GameShortName = "zeusandposeidon" # No Nexus support + GameSteamId = 566050 + GameGogId = 1207659039 + GameBinary = "Zeus.exe" + GameDataPath = "Adventures" + GameDocumentsDirectory = "%GAME_PATH%" + GameSavesDirectory = "%GAME_PATH%/Save" + GameSaveExtension = "sav" + GameSupportURL = ( + r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" + "Game:-Zeus%EF%BC%8BPoseidon" + ) + + def init(self, organizer: mobase.IOrganizer): + super().init(organizer) + self._register_feature(ZeusAndPoseidonModDataChecker()) + return True diff --git a/libs/basic_games/games/oblivion_remaster/__init__.py b/libs/basic_games/games/oblivion_remaster/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/__init__.py diff --git a/libs/basic_games/games/oblivion_remaster/constants.py b/libs/basic_games/games/oblivion_remaster/constants.py new file mode 100644 index 0000000..656e296 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/constants.py @@ -0,0 +1,14 @@ +from typing import TypedDict + +PLUGIN_NAME = "Oblivion Remastered Support Plugin" + + +class UE4SSModInfo(TypedDict): + mod_name: str + mod_enabled: bool + + +DEFAULT_UE4SS_MODS: list[UE4SSModInfo] = [ + {"mod_name": "BPML_GenericFunctions", "mod_enabled": True}, + {"mod_name": "BPModLoaderMod", "mod_enabled": True}, +] diff --git a/libs/basic_games/games/oblivion_remaster/game_plugins.py b/libs/basic_games/games/oblivion_remaster/game_plugins.py new file mode 100644 index 0000000..6777da6 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/game_plugins.py @@ -0,0 +1,207 @@ +from functools import cmp_to_key +from typing import Sequence + +from PyQt6.QtCore import ( + QByteArray, + QCoreApplication, + QDateTime, + QFile, + QFileInfo, + QStringConverter, + QStringEncoder, + qCritical, + qWarning, +) + +import mobase + + +class OblivionRemasteredGamePlugins(mobase.GamePlugins): + """ + Reimplementation of GameGamebryo "GamePlugins" code, in the Skyrim style. + + Should properly account for disabled plugins and the loadorder.txt profile file. + """ + + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._last_read = QDateTime().currentDateTime() + self._organizer = organizer + # Not currently used. These plugins exist in the base game but are not enabled by default. + self._plugin_blacklist = ["TamrielLevelledRegion.esp", "AltarGymNavigation.esp"] + + def writePluginLists(self, plugin_list: mobase.IPluginList) -> None: + if not self._last_read.isValid(): + return + self.writePluginList( + plugin_list, self._organizer.profile().absolutePath() + "/plugins.txt" + ) + self.writeLoadOrderList( + plugin_list, self._organizer.profile().absolutePath() + "/loadorder.txt" + ) + self._last_read = QDateTime.currentDateTime() + + def readPluginLists(self, plugin_list: mobase.IPluginList) -> None: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + load_order = self.readLoadOrderList(plugin_list, load_order_path) + plugin_list.setLoadOrder(load_order) + self.readPluginList(plugin_list) + self._last_read = QDateTime.currentDateTime() + + def getLoadOrder(self) -> Sequence[str]: + load_order_path = self._organizer.profile().absolutePath() + "/loadorder.txt" + plugins_path = self._organizer.profile().absolutePath() + "/plugins.txt" + + load_order_is_new = ( + not self._last_read.isValid() + or not QFileInfo(load_order_path).exists() + or QFileInfo(load_order_path).lastModified() > self._last_read + ) + plugins_is_new = ( + not self._last_read.isValid() + or QFileInfo(plugins_path).lastModified() > self._last_read + ) + + if load_order_is_new or not plugins_is_new: + return self.readLoadOrderList(self._organizer.pluginList(), load_order_path) + else: + return self.readPluginList(self._organizer.pluginList()) + + def writePluginList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, False) + + def writeLoadOrderList(self, plugin_list: mobase.IPluginList, filePath: str): + self.writeList(plugin_list, filePath, True) + + def writeList( + self, plugin_list: mobase.IPluginList, filePath: str, load_order: bool + ): + plugins_file = open(filePath, "w") + encoder = ( + QStringEncoder(QStringConverter.Encoding.Utf8) + if load_order + else QStringEncoder(QStringConverter.Encoding.System) + ) + plugins_text = "# This file was automatically generated by Mod Organizer.\n" + invalid_filenames = False + written_count = 0 + plugins = plugin_list.pluginNames() + plugins_sorted = sorted( + plugins, + key=cmp_to_key( + lambda lhs, rhs: plugin_list.priority(lhs) - plugin_list.priority(rhs) + ), + ) + for plugin_name in plugins_sorted: + if ( + load_order + or plugin_list.state(plugin_name) == mobase.PluginState.ACTIVE + ): + result = encoder.encode(plugin_name) + if encoder.hasError(): + invalid_filenames = True + qCritical("invalid plugin name %s" % plugin_name) + plugins_text += result.data().decode() + "\n" + written_count += 1 + + if invalid_filenames: + qCritical( + QCoreApplication.translate( + "MainWindow", + "Some of your plugins have invalid names! These " + + "plugins can not be loaded by the game. Please see " + + "mo_interface.log for a list of affected plugins " + + "and rename them.", + ) + ) + + if written_count == 0: + qWarning( + "plugin list would be empty, this is almost certainly wrong. Not saving." + ) + else: + plugins_file.write(plugins_text) + plugins_file.close() + + def readLoadOrderList( + self, plugin_list: mobase.IPluginList, file_path: str + ) -> list[str]: + plugin_names = [ + plugin for plugin in self._organizer.managedGame().primaryPlugins() + ] + plugin_lookup: set[str] = set() + for name in plugin_names: + if name.lower() not in plugin_lookup: + plugin_lookup.add(name.lower()) + + try: + with open(file_path) as file: + for line in file: + if line.startswith("#"): + continue + plugin_file = line.rstrip("\n") + if plugin_file.lower() not in plugin_lookup: + plugin_lookup.add(plugin_file.lower()) + plugin_names.append(plugin_file) + except FileNotFoundError: + return self.readPluginList(plugin_list) + + return plugin_names + + def readPluginList(self, plugin_list: mobase.IPluginList) -> list[str]: + plugins = [plugin for plugin in plugin_list.pluginNames()] + sorted_plugins: list[str] = [] + primary = [plugin for plugin in self._organizer.managedGame().primaryPlugins()] + primary_lower = [plugin.lower() for plugin in primary] + for plugin_name in primary: + if plugin_list.state(plugin_name) != mobase.PluginState.MISSING: + plugin_list.setState(plugin_name, mobase.PluginState.ACTIVE) + sorted_plugins.append(plugin_name) + plugin_remove = [ + plugin for plugin in plugins if plugin.lower() in primary_lower + ] + for plugin in plugin_remove: + plugins.remove(plugin) + + plugins_txt_exists = True + file_path = self._organizer.profile().absolutePath() + "/plugins.txt" + file = QFile(file_path) + if not file.open(QFile.OpenModeFlag.ReadOnly): + plugins_txt_exists = False + if file.size() == 0: + plugins_txt_exists = False + if plugins_txt_exists: + while not file.atEnd(): + line = file.readLine() + file_plugin_name = QByteArray() + if line.size() > 0 and line.at(0).decode() != "#": + encoder = QStringEncoder(QStringEncoder.Encoding.System) + file_plugin_name = encoder.encode(line.trimmed().data().decode()) + if file_plugin_name.size() > 0: + if file_plugin_name.data().decode().lower() in [ + plugin.lower() for plugin in plugins + ]: + plugin_list.setState( + file_plugin_name.data().decode(), mobase.PluginState.ACTIVE + ) + sorted_plugins.append(file_plugin_name.data().decode()) + plugins.remove(file_plugin_name.data().decode()) + + file.close() + + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + else: + for plugin_name in plugins: + plugin_list.setState(plugin_name, mobase.PluginState.INACTIVE) + + return sorted_plugins + plugins + + def lightPluginsAreSupported(self) -> bool: + return False + + def mediumPluginsAreSupported(self) -> bool: + return False + + def blueprintPluginsAreSupported(self) -> bool: + return False diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_checker.py b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py new file mode 100644 index 0000000..51875b2 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py @@ -0,0 +1,451 @@ +from typing import cast + +import mobase + +from .constants import PLUGIN_NAME + + +def _parent(entry: mobase.FileTreeEntry): + """ + Same as entry.parent() but always returns a mobase.IFileTree and never None. + """ + return cast(mobase.IFileTree, entry.parent()) + + +class OblivionRemasteredModDataChecker(mobase.ModDataChecker): + # These directories are generally considered valid, but may require additional checks. + # These represent top level directories in the mod. + _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "GameSettings", "Root"] + # Directories considered valid for 'Data' though many of these may be obsolete. + _data_dirs = [ + "meshes", + "textures", + "music", + "fonts", + "interface", + "shaders", + "strings", + "materials", + ] + # Data file extensions considered valid. Unclear if BSAs are actually used. + _data_extensions = [".esm", ".esp", ".bsa"] + + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._organizer = organizer + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + # These represent common mod structures that include UE4SS base files. + # These should generally be pruned or moved into a Root Builder path. + if filetree.find("ue4ss/UE4SS.dll") is not None: + return mobase.ModDataChecker.FIXABLE + elif ( + filetree.find("OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll") + is not None + ): + return mobase.ModDataChecker.FIXABLE + + # Crawl the directory tree to check mod structure. + for entry in filetree: + name = entry.name().casefold() + parent = entry.parent() + assert parent is not None + # These are top-level file entries. + if parent.parent() is None: + if isinstance(entry, mobase.IFileTree): + # Look for valid top level directories. + if name in [dirname.lower() for dirname in self._dirs]: + if name == "ue4ss": + """ + The UE4SS mod directory should contain either mod directories with + a 'scripts/main.lua' file, or 'shared' library files. Certain common + 'preset settings' files are also acceptable. + """ + mods = entry.find("Mods") + if isinstance(mods, mobase.IFileTree): + """ + UE4SS intrinsically maps to the 'Mods' directory, so if this directory + is present, it should be relocated. + """ + for sub_entry in mods: + if isinstance(sub_entry, mobase.IFileTree): + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.FIXABLE + break + if sub_entry.name().casefold() in [ + "shared", + "npcappearancemanager", + "naturalbodymorph", + ]: + status = mobase.ModDataChecker.FIXABLE + break + else: + for sub_entry in entry: + if isinstance(sub_entry, mobase.IFileTree): + # Files are present in the correct directory. Mark valid. + if sub_entry.find("scripts/main.lua"): + status = mobase.ModDataChecker.VALID + break + if sub_entry.name().casefold() in [ + "shared", + "npcappearancemanager", + "naturalbodymorph", + ]: + status = mobase.ModDataChecker.VALID + break + else: + # All other base directories are considered valid + status = mobase.ModDataChecker.VALID + # No need to continue checks if the directory looks valid + if status == mobase.ModDataChecker.VALID: + break + elif name in [dirname.lower() for dirname in self._data_dirs]: + # Found a 'Data' subdirectory. Should be moved into 'Data'. + status = mobase.ModDataChecker.FIXABLE + else: + # Parse other directories for potential mod files. + for sub_entry in entry: + if sub_entry.isFile(): + sub_name = sub_entry.name().casefold() + if sub_name.endswith(".exe"): + # Trying to handle EXE files is problematic, let the user figure it out + return mobase.ModDataChecker.INVALID + if sub_name.endswith((".pak", ".bk2")): + # Found Pak files or movie files, should be fixable + status = mobase.ModDataChecker.FIXABLE + elif sub_name.endswith(tuple(self._data_extensions)): + # Found plugins or BSA files, should be fixable + status = mobase.ModDataChecker.FIXABLE + else: + if name == "Paks": + # Found Paks directory as subdirectory, should be fixable + status = mobase.ModDataChecker.FIXABLE + # Iterate into subdirectories so we can check the entire archive + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + if status == mobase.ModDataChecker.VALID: + break + else: + if name.endswith(".exe"): + return mobase.ModDataChecker.INVALID + if name.endswith(tuple(self._data_extensions + [".pak", ".bk2"])): + status = mobase.ModDataChecker.FIXABLE + else: + # Section is for parsing subdirectories + if isinstance(entry, mobase.IFileTree): + if name in [dir_name.lower() for dir_name in self._dirs]: + status = mobase.ModDataChecker.FIXABLE + if name in [dir_name.lower() for dir_name in self._data_dirs]: + status = mobase.ModDataChecker.FIXABLE + else: + new_status = self.dataLooksValid(entry) + if new_status != mobase.ModDataChecker.INVALID: + status = new_status + else: + if name.endswith(".exe"): + return mobase.ModDataChecker.INVALID + if name.endswith( + tuple(self._data_extensions + [".pak", ".lua", ".bk2"]) + ): + status = mobase.ModDataChecker.FIXABLE + if status == mobase.ModDataChecker.VALID: + break + return status + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + """ + Main fixer function. Iterates files, using 'parse_directory' for subdirectory search and fixing. + """ + + # UE4SS is packaged with many mods (or standalone) and should be processed into Root if found. + # This can avoid a lot of unnecessary iterations. + ue4ss_dll = filetree.find("ue4ss/UE4SS.dll") + if ue4ss_dll is None: + ue4ss_dll = filetree.find( + "OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll" + ) + if ue4ss_dll is not None and (ue4ss_folder := ue4ss_dll.parent()) is not None: + entries: list[mobase.FileTreeEntry] = [] + for entry in _parent(ue4ss_folder): + entries.append(entry) + for entry in entries: + filetree.move( + entry, + "Root/OblivionRemastered/Binaries/Win64/", + mobase.IFileTree.MERGE, + ) + + # Similar to the above, many mods pack files relative to the root game directory. Some common paths can be + # automatically moved into the appropriate directory structures to avoid needless iteration. + exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") + if isinstance(exe_dir, mobase.IFileTree): + gamesettings_dir = exe_dir.find("GameSettings") + if isinstance(gamesettings_dir, mobase.IFileTree): + gamesettings_main = self.get_dir(filetree, "GameSettings") + gamesettings_main.merge(gamesettings_dir, True) + self.detach_parents(gamesettings_dir) + obse_dir = exe_dir.find("OBSE") + if isinstance(obse_dir, mobase.IFileTree): + obse_main = self.get_dir(filetree, "OBSE") + obse_main.merge(obse_dir, True) + self.detach_parents(obse_dir) + ue4ss_mod_dir = exe_dir.find("ue4ss/Mods") + if isinstance(ue4ss_mod_dir, mobase.IFileTree): + if self._organizer.pluginSetting(PLUGIN_NAME, "ue4ss_use_root_builder"): + ue4ss_main = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + else: + ue4ss_main = self.get_dir(filetree, "UE4SS") + ue4ss_main.merge(ue4ss_mod_dir, True) + self.detach_parents(ue4ss_mod_dir) + if len(exe_dir): + root_exe_dir = self.get_dir( + filetree, "Root/OblivionRemastered/Binaries" + ) + parent = exe_dir.parent() + exe_dir.moveTo(root_exe_dir) + if parent: + self.detach_parents(parent) + else: + self.detach_parents(exe_dir) + + # Start the main directory iteration code + directories: list[mobase.IFileTree] = [] + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + directories.append(entry) + for directory in directories: + if directory.name().casefold() in [ + dirname.lower() for dirname in self._data_dirs + ]: + # Move detected 'Data' directories into 'Data' + data_dir = self.get_dir(filetree, "Data") + directory.moveTo(data_dir) + elif directory.name().casefold() == "ue4ss": + # Validate and correct UE4SS mod files into 'UE4SS' + # Alternately, can use Root Builder path per user request + mods = directory.find("Mods") + if isinstance(mods, mobase.IFileTree): + for sub_entry in mods: + if isinstance(sub_entry, mobase.IFileTree): + if ( + sub_entry.find("scripts/main.lua") + or sub_entry.name().casefold() == "shared" + ): + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + sub_entry.moveTo(ue4ss_main) + self.detach_parents(directory) + else: + parent = _parent(sub_entry) + sub_entry.moveTo(directory) + self.detach_parents(parent) + elif directory.name().casefold() not in [ + dirname.lower() for dirname in self._dirs + ]: + # For non-valid directories, iterate into the directory + filetree = self.parse_directory(filetree, directory) + # Parsing top-level files + entries: list[mobase.FileTreeEntry] = [] + # As we are changing the filetree, cache the current directory list and iterate on that + for entry in filetree: + entries.append(entry) + for entry in entries: + if entry.parent() == filetree and entry.isFile(): + name = entry.name().casefold() + if name.endswith(".pak"): + # Move all pak|ucas|utoc files into "Paks\~mods" + paks_dir = self.get_dir(filetree, "Paks/~mods") + pak_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if ( + file.name() + .casefold() + .endswith((".pak", ".ucas", ".utoc")) + ): + pak_files.append(file) + for pak_file in pak_files: + pak_file.moveTo(paks_dir) + elif name.endswith(".bk2"): + # Top-level bk2 files should be moved to "Movies\Modern" + movies_dir = self.get_dir(filetree, "Movies/Modern") + movie_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + if file.isFile(): + if file.name().casefold().endswith(".bk2"): + movie_files.append(file) + for movie_file in movie_files: + movie_file.moveTo(movies_dir) + elif name.endswith(tuple(self._data_extensions)): + # Files matching Data file extensions should be moved to "Data" + data_dir = self.get_dir(filetree, "Data") + data_files: list[mobase.FileTreeEntry] = [] + for file in _parent(entry): + data_files.append(file) + for data_file in data_files: + data_file.moveTo(data_dir) + return filetree + + def parse_directory( + self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree + ) -> mobase.IFileTree: + """ + Subdirectory iterator for fix(). Allows parsing all subdirectory files. + + :param main_filetree: The main mod filetree that should be returned. + :param next_dir: The currently processed subdirectory of main_filetree. + :returns: The updated main_filetree. + """ + directories: list[mobase.IFileTree] = [] + for entry in next_dir: + if isinstance(entry, mobase.IFileTree): + directories.append(entry) + for directory in directories: + name = directory.name().casefold() + stop = False + for dir_name in self._dirs: + if name == dir_name.lower(): + main_dir = self.get_dir(main_filetree, dir_name) + if name == "ue4ss": + # UE4SS directories should presumably map to 'UE4SS' but check for a 'Mods' directory and move that instead. + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_dir = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss", + ) + ue4ss_dir.merge(directory) + else: + mod_dir = directory.find("Mods") + if isinstance(mod_dir, mobase.IFileTree): + main_dir.merge(mod_dir) + else: + main_dir.merge(directory) + else: + main_dir.merge(directory) + self.detach_parents(directory) + stop = True + break + if stop: + continue + if name in ["~mods", "logicmods"]: + # These directories should represent Paks mods and should be moved into that directory. + paks_dir = self.get_dir(main_filetree, "Paks") + directory.moveTo(paks_dir) + continue + elif name in [dirname.lower() for dirname in self._data_dirs]: + # These directories are typically associated with Data and should be moved into that directory. + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(directory) + self.detach_parents(directory) + continue + main_filetree = self.parse_directory(main_filetree, directory) + for entry in next_dir: + if entry.isFile(): + name = entry.name().casefold() + if name.endswith(tuple(self._data_extensions)): + # Files matching Data extensions should be moved into 'Data' + data_dir = self.get_dir(main_filetree, "Data") + data_dir.merge(next_dir) + self.detach_parents(next_dir) + elif name.endswith(".pak"): + # Loose pak files most likely should be installed to 'Paks\~mods' but check the parent directory + paks_dir = self.get_dir(main_filetree, "Paks") + if next_dir.name().casefold() == "paks": + paks_dir.merge(next_dir) + self.detach_parents(next_dir) + return main_filetree + elif next_dir.name().casefold() in ["~mods", "logicmods"]: + next_dir.moveTo(paks_dir) + return main_filetree + else: + parent = _parent(next_dir) + main_filetree.move( + next_dir, "Paks/~mods/", mobase.IFileTree.MERGE + ) + + self.detach_parents(parent) + return main_filetree + elif name.endswith(".lua"): + # LUA files are generally associated with UE4SS mods. Most will probably be found before this point. + if next_dir.parent() and next_dir.parent() != main_filetree: + parent = _parent(next_dir).parent() + if self._organizer.pluginSetting( + PLUGIN_NAME, "ue4ss_use_root_builder" + ): + ue4ss_main = self.get_dir( + main_filetree, + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods", + ) + _parent(next_dir).moveTo(ue4ss_main) + else: + if main_filetree.find("UE4SS") is None: + main_filetree.addDirectory("UE4SS") + main_filetree.move( + _parent(next_dir), + "UE4SS/", + mobase.IFileTree.MERGE, + ) + if parent is not None: + self.detach_parents(parent) + return main_filetree + elif name.endswith(".bk2"): + movies_dir = self.get_dir(main_filetree, "Movies/Modern") + movies_dir.merge(next_dir) + self.detach_parents(next_dir) + + return main_filetree + + def detach_parents(self, directory: mobase.IFileTree) -> None: + """ + Attempts to clean up empty archive directories after files have been moved. + Find the top-most directory of an empty file tree where only the child directory is contained. + Remove that filetree. + + :param directory: The directory tree to be pruned (starting with the lowest element). + """ + + if ( + directory.parent() is not None + and (parent := directory.parent()) is not None + and len(parent) == 1 + ): + parent = parent if parent.parent() is not None else directory + while ( + parent + and (p_parent := parent.parent()) is not None + and (pp_parent := p_parent.parent()) is not None + and len(pp_parent) == 1 + ): + parent = parent.parent() + + assert parent is not None + parent.detach() + else: + directory.detach() + + def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree: + """ + Simple helper function that finds or creates a directory within a given filetree. + + :param filetree: The filetree in which to file or create the given directory + :param directory: The directory name to return + :return: The filetree of the created or found directory. + """ + + tree_dir = filetree.find(directory) + if not isinstance(tree_dir, mobase.IFileTree): + tree_dir = filetree.addDirectory(directory) + return tree_dir diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_content.py b/libs/basic_games/games/oblivion_remaster/mod_data_content.py new file mode 100644 index 0000000..2ee7e5f --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/mod_data_content.py @@ -0,0 +1,109 @@ +from enum import IntEnum, auto + +import mobase + + +class Content(IntEnum): + PLUGIN = auto() + BSA = auto() + PAK = auto() + OBSE = auto() + OBSE_FILES = auto() + MOVIE = auto() + UE4SS = auto() + MAGIC_LOADER = auto() + GAME_SETTINGS = auto() + + +class OblivionRemasteredDataContent(mobase.ModDataContent): + OR_CONTENTS: list[tuple[Content, str, str, bool] | tuple[Content, str, str]] = [ + (Content.PLUGIN, "Plugins (ESM/ESP)", ":/MO/gui/content/plugin"), + (Content.BSA, "Bethesda Archive", ":/MO/gui/content/bsa"), + (Content.PAK, "Paks", ":/MO/gui/content/geometries"), + (Content.OBSE, "Script Extender Plugin", ":/MO/gui/content/skse"), + (Content.OBSE_FILES, "Script Extender Files", "", True), + (Content.MOVIE, "Movies", ":/MO/gui/content/media"), + (Content.UE4SS, "UE4SS Mods", ":/MO/gui/content/script"), + (Content.MAGIC_LOADER, "Magic Loader Mod", ":/MO/gui/content/inifile"), + (Content.GAME_SETTINGS, "Game Settings", ":/MO/gui/content/menu"), + ] + + def getAllContents(self) -> list[mobase.ModDataContent.Content]: + return [ + mobase.ModDataContent.Content(id, name, icon, *filter_only) + for id, name, icon, *filter_only in self.OR_CONTENTS + ] + + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: + contents: set[int] = set() + + for entry in filetree: + if isinstance(entry, mobase.IFileTree): + match entry.name().casefold(): + case "data": + for data_entry in entry: + if data_entry.isFile(): + match data_entry.suffix().casefold(): + case "esm" | "esp": + contents.add(Content.PLUGIN) + case "bsa": + contents.add(Content.BSA) + case _: + pass + else: + match data_entry.name().casefold(): + case "magicloader": + contents.add(Content.MAGIC_LOADER) + case _: + pass + case "obse": + contents.add(Content.OBSE_FILES) + plugins_dir = entry.find("Plugins") + if isinstance(plugins_dir, mobase.IFileTree): + for plugin_entry in plugins_dir: + if ( + plugin_entry.isFile() + and plugin_entry.suffix().casefold() == "dll" + ): + contents.add(Content.OBSE) + if ( + isinstance(plugin_entry, mobase.IFileTree) + and plugins_dir.name().casefold() == "gamesettings" + ): + for settings_file in plugin_entry: + if ( + settings_file.isFile() + and settings_file.suffix().casefold() + == "ini" + ): + contents.add(Content.GAME_SETTINGS) + case "paks": + contents.add(Content.PAK) + for paks_entry in entry: + if isinstance(paks_entry, mobase.IFileTree): + if paks_entry.name().casefold() == "~mods": + for mods_entry in paks_entry: + if isinstance(mods_entry, mobase.IFileTree): + if ( + "magicloader" + in mods_entry.name().casefold() + ): + contents.add(Content.MAGIC_LOADER) + break + if paks_entry.name().casefold() == "logicmods": + contents.add(Content.UE4SS) + case "movies": + contents.add(Content.MOVIE) + case "ue4ss": + contents.add(Content.UE4SS) + case "gamesettings": + for settings_file in entry: + if ( + settings_file.isFile() + and settings_file.suffix().casefold() == "ini" + ): + contents.add(Content.GAME_SETTINGS) + case _: + pass + + return list(contents) diff --git a/libs/basic_games/games/oblivion_remaster/paks/__init__.py b/libs/basic_games/games/oblivion_remaster/paks/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/paks/__init__.py diff --git a/libs/basic_games/games/oblivion_remaster/paks/model.py b/libs/basic_games/games/oblivion_remaster/paks/model.py new file mode 100644 index 0000000..c43cec0 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/paks/model.py @@ -0,0 +1,253 @@ +import itertools +import typing +from enum import IntEnum, auto +from typing import Any, TypeAlias, overload + +from PyQt6.QtCore import ( + QAbstractItemModel, + QByteArray, + QDataStream, + QDir, + QFileInfo, + QMimeData, + QModelIndex, + QObject, + Qt, + QVariant, +) +from PyQt6.QtWidgets import QWidget + +import mobase + +_PakInfo: TypeAlias = tuple[str, str, str, str] + + +class PaksColumns(IntEnum): + PRIORITY = auto() + PAK_NAME = auto() + SOURCE = auto() + + +class PaksModel(QAbstractItemModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self.paks: dict[int, _PakInfo] = {} + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + if paks_txt.exists(): + with open(paks_txt.absoluteFilePath(), "r") as paks_file: + index = 0 + for line in paks_file: + self.paks[index] = (line, "", "", "") + index += 1 + + def set_paks(self, paks: dict[int, _PakInfo]): + self.layoutAboutToBeChanged.emit() + self.paks = paks + self.layoutChanged.emit() + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount(), self.columnCount()), + [Qt.ItemDataRole.DisplayRole], + ) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + super().flags(index) + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(PaksColumns) + + def index( + self, row: int, column: int, parent: QModelIndex = QModelIndex() + ) -> QModelIndex: + if ( + row < 0 + or row >= self.rowCount() + or column < 0 + or column >= self.columnCount() + ): + return QModelIndex() + return self.createIndex(row, column, row) + + @overload + def parent(self, child: QModelIndex) -> QModelIndex: ... + @overload + def parent(self) -> QObject | None: ... + + def parent(self, child: QModelIndex | None = None) -> QModelIndex | QObject | None: + if child is None: + return super().parent() + return QModelIndex() + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self.paks) + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + return False + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> typing.Any: + if ( + orientation != Qt.Orientation.Horizontal + or role != Qt.ItemDataRole.DisplayRole + ): + return QVariant() + + column = PaksColumns(section + 1) + match column: + case PaksColumns.PAK_NAME: + return "Pak Group" + case PaksColumns.PRIORITY: + return "Priority" + case PaksColumns.SOURCE: + return "Source" + + return QVariant() + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + if index.column() + 1 == PaksColumns.PAK_NAME: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][0] + elif index.column() + 1 == PaksColumns.PRIORITY: + if role == Qt.ItemDataRole.DisplayRole: + return index.row() + elif index.column() + 1 == PaksColumns.SOURCE: + if role == Qt.ItemDataRole.DisplayRole: + return self.paks[index.row()][1] + return QVariant() + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False + + def supportedDropActions(self) -> Qt.DropAction: + return Qt.DropAction.MoveAction + + def dropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.IgnoreAction: + return True + + if data is None: + return False + + encoded: QByteArray = data.data("application/x-qabstractitemmodeldatalist") + stream: QDataStream = QDataStream(encoded, QDataStream.OpenModeFlag.ReadOnly) + source_rows: list[int] = [] + + while not stream.atEnd(): + source_row = stream.readInt() + col = stream.readInt() + size = stream.readInt() + item_data = {} + for _ in range(size): + role = stream.readInt() + value = stream.readQVariant() + item_data[role] = value + if col == 0: + source_rows.append(source_row) + + if row == -1: + row = parent.row() + + if row < 0 or row >= len(self.paks): + new_priority = len(self.paks) + else: + new_priority = row + + before_paks: list[_PakInfo] = [] + moved_paks: list[_PakInfo] = [] + after_paks: list[_PakInfo] = [] + before_paks_p: list[_PakInfo] = [] + moved_paks_p: list[_PakInfo] = [] + after_paks_p: list[_PakInfo] = [] + for row, paks in sorted(self.paks.items()): + if row < new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + before_paks_p.append(paks) + else: + before_paks.append(paks) + if row >= new_priority: + if row in source_rows: + if paks[0].casefold()[-2:] == "_p": + moved_paks_p.append(paks) + else: + moved_paks.append(paks) + else: + if paks[0].casefold()[-2:] == "_p": + after_paks_p.append(paks) + else: + after_paks.append(paks) + + new_paks = dict( + enumerate( + itertools.chain( + before_paks, + moved_paks, + after_paks, + before_paks_p, + moved_paks_p, + after_paks_p, + ) + ) + ) + + index = 8999 + for row, pak in new_paks.items(): + current_dir = QDir(pak[2]) + parent_dir = QDir(pak[2]) + parent_dir.cdUp() + if current_dir.exists() and parent_dir.dirName().casefold() == "~mods": + new_paks[row] = ( + pak[0], + pak[1], + pak[2], + parent_dir.absoluteFilePath(str(index).zfill(4)), + ) + index -= 1 + + self.set_paks(new_paks) + return False diff --git a/libs/basic_games/games/oblivion_remaster/paks/view.py b/libs/basic_games/games/oblivion_remaster/paks/view.py new file mode 100644 index 0000000..a56b0cd --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/paks/view.py @@ -0,0 +1,33 @@ +from typing import Iterable + +from PyQt6.QtCore import QModelIndex, Qt, pyqtSignal +from PyQt6.QtGui import QDropEvent +from PyQt6.QtWidgets import QAbstractItemView, QTreeView, QWidget + + +class PaksView(QTreeView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + if (viewport := self.viewport()) is not None: + viewport.setAcceptDrops(True) + self.setItemsExpandable(False) + self.setRootIsDecorated(False) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.clearSelection() + self.data_dropped.emit() + + def dataChanged( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () + ): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() diff --git a/libs/basic_games/games/oblivion_remaster/paks/widget.py b/libs/basic_games/games/oblivion_remaster/paks/widget.py new file mode 100644 index 0000000..efc3038 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/paks/widget.py @@ -0,0 +1,213 @@ +from functools import cmp_to_key +from pathlib import Path +from typing import cast + +from PyQt6.QtCore import QDir, QFileInfo +from PyQt6.QtWidgets import QGridLayout, QWidget + +import mobase + +from ....basic_features.utils import is_directory +from .model import PaksModel +from .view import PaksView + + +def pak_sort(a: tuple[str, str], b: tuple[str, str]) -> int: + a_pak, a_str = a[0], a[1] or a[0] + b_pak, b_str = b[0], b[1] or b[0] + + a_pak_ends_p = a_pak.casefold().endswith("_p") + b_pak_ends_p = b_pak.casefold().endswith("_p") + + if a_pak_ends_p == b_pak_ends_p: + if a_str.casefold() <= b_str.casefold(): + return 1 + return -1 + elif a_pak_ends_p: + return 1 + elif b_pak_ends_p: + return -1 + return 0 + + +class PaksTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = PaksView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = PaksModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_paks_list) # type: ignore + self._view.data_dropped.connect(self.write_paks_list) # type: ignore + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_pak_files()) + organizer.modList().onModInstalled(lambda mod: self._parse_pak_files()) + organizer.modList().onModRemoved(lambda mod: self._parse_pak_files()) + organizer.modList().onModStateChanged(lambda mods: self._parse_pak_files()) + self._parse_pak_files() + + def load_paks_list(self) -> list[str]: + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + paks_list: list[str] = [] + if paks_txt.exists(): + with open(paks_txt.absoluteFilePath(), "r") as paks_file: + for line in paks_file: + paks_list.append(line.strip()) + return paks_list + + def write_paks_list(self): + profile = QDir(self._organizer.profilePath()) + paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) + with open(paks_txt.absoluteFilePath(), "w") as paks_file: + for _, pak in sorted(self._model.paks.items()): + name, _, _, _ = pak + paks_file.write(f"{name}\n") + self.write_pak_files() + + def write_pak_files(self): + for index, pak in sorted(self._model.paks.items()): + _, _, current_path, target_path = pak + if current_path and current_path != target_path: + path_dir = Path(current_path) + target_dir = Path(target_path) + if not target_dir.exists(): + target_dir.mkdir(parents=True, exist_ok=True) + if path_dir.exists(): + for pak_file in path_dir.glob("*.pak"): + ucas_file = pak_file.with_suffix(".ucas") + utoc_file = pak_file.with_suffix(".utoc") + for file in (pak_file, ucas_file, utoc_file): + if not file.exists(): + continue + try: + file.rename(target_dir.joinpath(file.name)) + except FileExistsError: + pass + data = self._model.paks[index] + self._model.paks[index] = ( + data[0], + data[1], + data[3], + data[3], + ) + break + if not list(path_dir.iterdir()): + path_dir.rmdir() + + def _shake_paks(self, sorted_paks: dict[str, str]) -> list[str]: + shaken_paks: list[str] = [] + shaken_paks_p: list[str] = [] + paks_list = self.load_paks_list() + for pak in paks_list: + if pak in sorted_paks.keys(): + if pak.casefold().endswith("_p"): + shaken_paks_p.append(pak) + else: + shaken_paks.append(pak) + sorted_paks.pop(pak) + for pak in sorted_paks.keys(): + if pak.casefold().endswith("_p"): + shaken_paks_p.append(pak) + else: + shaken_paks.append(pak) + return shaken_paks + shaken_paks_p + + def _parse_pak_files(self): + from ...game_oblivion_remaster import OblivionRemasteredGame + + mods = self._organizer.modList().allMods() + paks: dict[str, str] = {} + pak_paths: dict[str, tuple[str, str]] = {} + pak_source: dict[str, str] = {} + for mod in mods: + mod_item = self._organizer.modList().getMod(mod) + if not self._organizer.modList().state(mod) & mobase.ModState.ACTIVE: + continue + filetree = mod_item.fileTree() + pak_mods = filetree.find("Paks/~mods") + if not pak_mods: + pak_mods = filetree.find("Root/OblivionRemastered/Content/Paks/~mods") + if isinstance(pak_mods, mobase.IFileTree): + for entry in pak_mods: + if is_directory(entry): + if "magicloader" in entry.name().casefold(): + continue + for sub_entry in entry: + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + pak_name = sub_entry.name()[ + : -1 - len(sub_entry.suffix()) + ] + paks[pak_name] = entry.name() + pak_paths[pak_name] = ( + mod_item.absolutePath() + + "/" + + cast(mobase.IFileTree, sub_entry.parent()).path( + "/" + ), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[pak_name] = mod_item.name() + else: + if entry.suffix().casefold() == "pak": + pak_name = entry.name()[: -1 - len(entry.suffix())] + paks[pak_name] = "" + pak_paths[pak_name] = ( + mod_item.absolutePath() + + "/" + + cast(mobase.IFileTree, entry.parent()).path("/"), + mod_item.absolutePath() + "/" + pak_mods.path("/"), + ) + pak_source[pak_name] = mod_item.name() + game = self._organizer.managedGame() + if isinstance(game, OblivionRemasteredGame): + pak_mods = QFileInfo(game.paksDirectory().absoluteFilePath("~mods")) + if pak_mods.exists() and pak_mods.isDir(): + for entry in QDir(pak_mods.absoluteFilePath()).entryInfoList( + QDir.Filter.Dirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot + ): + if entry.isDir(): + if "magicloader" in entry.completeBaseName().casefold(): + continue + for sub_entry in QDir(entry.absoluteFilePath()).entryInfoList( + QDir.Filter.Files + ): + if ( + sub_entry.isFile() + and sub_entry.suffix().casefold() == "pak" + ): + pak_name = sub_entry.completeBaseName() + paks[pak_name] = entry.completeBaseName() + pak_paths[pak_name] = ( + sub_entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[pak_name] = "Game Directory" + else: + if entry.suffix().casefold() == "pak": + pak_name = entry.completeBaseName() + paks[pak_name] = "" + pak_paths[pak_name] = ( + entry.absolutePath(), + pak_mods.absolutePath(), + ) + pak_source[pak_name] = "Game Directory" + sorted_paks = dict(sorted(paks.items(), key=cmp_to_key(pak_sort))) + shaken_paks: list[str] = self._shake_paks(sorted_paks) + final_paks: dict[str, tuple[str, str, str]] = {} + pak_index = 8999 + for pak in shaken_paks: + target_dir = pak_paths[pak][1] + "/" + str(pak_index).zfill(4) + final_paks[pak] = (pak_source[pak], pak_paths[pak][0], target_dir) + pak_index -= 1 + new_data_paks: dict[int, tuple[str, str, str, str]] = {} + i = 0 + for pak, data in final_paks.items(): + source, current_path, target_path = data + new_data_paks[i] = (pak, source, current_path, target_path) + i += 1 + self._model.set_paks(new_data_paks) diff --git a/libs/basic_games/games/oblivion_remaster/script_extender.py b/libs/basic_games/games/oblivion_remaster/script_extender.py new file mode 100644 index 0000000..571e675 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/script_extender.py @@ -0,0 +1,37 @@ +from pathlib import Path + +import mobase + + +class OblivionRemasteredScriptExtender(mobase.ScriptExtender): + def __init__(self, game: mobase.IPluginGame): + super().__init__() + self._game = game + + def binaryName(self): + return "obse64_loader.exe" + + def loaderName(self) -> str: + return self.binaryName() + + def loaderPath(self) -> str: + return ( + self._game.gameDirectory().absolutePath() + + "\\OblivionRemastered\\Binaries\\Win64\\" + + self.loaderName() + ) + + def pluginPath(self) -> str: + return "OBSE/Plugins" + + def savegameExtension(self) -> str: + return "" + + def isInstalled(self) -> bool: + return Path(self.loaderPath()).exists() + + def getExtenderVersion(self) -> str: + return mobase.getFileVersion(self.loaderPath()) + + def getArch(self) -> int: + return 0x8664 if self.isInstalled() else 0x0 diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py b/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/ue4ss/__init__.py diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/model.py b/libs/basic_games/games/oblivion_remaster/ue4ss/model.py new file mode 100644 index 0000000..a3804fe --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/ue4ss/model.py @@ -0,0 +1,125 @@ +import json +from json import JSONDecodeError +from typing import Any, Iterable + +from PyQt6.QtCore import ( + QDir, + QFileInfo, + QMimeData, + QModelIndex, + QStringListModel, + Qt, +) +from PyQt6.QtWidgets import QWidget + +import mobase + +from ..constants import DEFAULT_UE4SS_MODS + + +class UE4SSListModel(QStringListModel): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._checked_items: set[str] = set() + self._organizer = organizer + self._init_mod_states() + + def _init_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + try: + mod_data = json.load(json_file) + except JSONDecodeError: + mod_data = DEFAULT_UE4SS_MODS + for mod in mod_data: + if mod["mod_enabled"]: + self._checked_items.add(mod["mod_name"]) + + def _set_mod_states(self): + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mod_list: dict[str, bool] = {} + if mods_json.exists(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + try: + mod_data = json.load(json_file) + except JSONDecodeError: + mod_data = DEFAULT_UE4SS_MODS + for mod in mod_data: + mod_list[mod["mod_name"]] = mod["mod_enabled"] + for i in range(self.rowCount()): + item = self.index(i, 0) + name = self.data(item, Qt.ItemDataRole.DisplayRole) + if name in mod_list: + self.setData( + item, + True if mod_list[name] else False, + Qt.ItemDataRole.CheckStateRole, + ) + else: + self.setData(item, True, Qt.ItemDataRole.CheckStateRole) + + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + flags = super().flags(index) + if not index.isValid(): + return ( + Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEnabled + ) + return ( + flags + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsDragEnabled & Qt.ItemFlag.ItemIsEditable + ) + + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if not index.isValid() or role != Qt.ItemDataRole.CheckStateRole: + return False + + if ( + bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) not in self._checked_items + ): + self._checked_items.add(self.data(index, Qt.ItemDataRole.DisplayRole)) + elif ( + not bool(value) + and self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + ): + self._checked_items.remove(self.data(index, Qt.ItemDataRole.DisplayRole)) + self.dataChanged.emit(index, index, [role]) + return True + + def setStringList(self, strings: Iterable[str | None]): + super().setStringList(strings) + self._set_mod_states() + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + + if role == Qt.ItemDataRole.CheckStateRole: + return ( + Qt.CheckState.Checked + if self.data(index, Qt.ItemDataRole.DisplayRole) in self._checked_items + else Qt.CheckState.Unchecked + ) + + return super().data(index, role) + + def canDropMimeData( + self, + data: QMimeData | None, + action: Qt.DropAction, + row: int, + column: int, + parent: QModelIndex, + ) -> bool: + if action == Qt.DropAction.MoveAction and (row != -1 or column != -1): + return True + return False diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/view.py b/libs/basic_games/games/oblivion_remaster/ue4ss/view.py new file mode 100644 index 0000000..bb994ca --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/ue4ss/view.py @@ -0,0 +1,31 @@ +from typing import Iterable + +from PyQt6.QtCore import QModelIndex, Qt, pyqtSignal +from PyQt6.QtGui import QDropEvent +from PyQt6.QtWidgets import QAbstractItemView, QListView, QWidget + + +class UE4SSView(QListView): + data_dropped = pyqtSignal() + + def __init__(self, parent: QWidget | None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + if (viewport := self.viewport()) is not None: + viewport.setAcceptDrops(True) + + def dropEvent(self, e: QDropEvent | None): + super().dropEvent(e) + self.data_dropped.emit() + + def dataChanged( + self, topLeft: QModelIndex, bottomRight: QModelIndex, roles: Iterable[int] = () + ): + super().dataChanged(topLeft, bottomRight, roles) + self.repaint() diff --git a/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py b/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py new file mode 100644 index 0000000..246b7fa --- /dev/null +++ b/libs/basic_games/games/oblivion_remaster/ue4ss/widget.py @@ -0,0 +1,175 @@ +import json +from functools import cmp_to_key +from json import JSONDecodeError +from pathlib import Path + +from PyQt6.QtCore import QDir, QFileInfo, Qt +from PyQt6.QtWidgets import QGridLayout, QWidget + +import mobase + +from ..constants import DEFAULT_UE4SS_MODS, UE4SSModInfo +from .model import UE4SSListModel +from .view import UE4SSView + + +class UE4SSTabWidget(QWidget): + def __init__(self, parent: QWidget | None, organizer: mobase.IOrganizer): + super().__init__(parent) + self._organizer = organizer + self._view = UE4SSView(self) + self._layout = QGridLayout(self) + self._layout.addWidget(self._view) + self._model = UE4SSListModel(self._view, organizer) + self._view.setModel(self._model) + self._model.dataChanged.connect(self.write_mod_list) # type: ignore + self._view.data_dropped.connect(self.write_mod_list) # type: ignore + organizer.onProfileChanged(lambda profile_a, profile_b: self._parse_mod_files()) + organizer.modList().onModInstalled(self.update_mod_files) + organizer.modList().onModRemoved(lambda mod: self._parse_mod_files()) + organizer.modList().onModStateChanged(self.update_mod_files) + self._parse_mod_files() + + def get_mod_list(self) -> list[str]: + mod_list: list[str] = [] + for index in range(self._model.rowCount()): + mod_list.append( + self._model.data( + self._model.index(index, 0), Qt.ItemDataRole.DisplayRole + ) + ) + return mod_list + + def update_mod_files( + self, mods: dict[str, mobase.ModState] | mobase.IModInterface | str + ): + mod_list: list[mobase.IModInterface] = [] + if isinstance(mods, dict): + for mod in mods.keys(): + mod_list.append(self._organizer.modList().getMod(mod)) + elif isinstance(mods, mobase.IModInterface): + mod_list.append(mods) + else: + mod_list.append(self._organizer.modList().getMod(mods)) + + for mod in mod_list: + tree = mod.fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_files, mobase.IFileTree): + for entry in ue4ss_files: + if isinstance(entry, mobase.IFileTree): + if enabled_txt := entry.find("enabled.txt"): + try: + Path(mod.absolutePath(), enabled_txt.path("/")).unlink() + self._organizer.modDataChanged(mod) + except FileNotFoundError: + pass + + self._parse_mod_files() + + def _parse_mod_files(self): + from ...game_oblivion_remaster import OblivionRemasteredGame + + mod_list: set[str] = set() + for mod in self._organizer.modList().allMods(): + if ( + mobase.ModState(self._organizer.modList().state(mod)) + & mobase.ModState.ACTIVE + ): + tree = self._organizer.modList().getMod(mod).fileTree() + ue4ss_files = tree.find("UE4SS") + if not ue4ss_files: + ue4ss_files = tree.find( + "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods" + ) + if isinstance(ue4ss_files, mobase.IFileTree): + for entry in ue4ss_files: + if isinstance(entry, mobase.IFileTree): + if entry.find("scripts/main.lua"): + mod_list.add(entry.name()) + if enabled_txt := entry.find("enabled.txt"): + try: + Path( + self._organizer.modList() + .getMod(mod) + .absolutePath(), + enabled_txt.path("/"), + ).unlink() + self._organizer.modDataChanged( + self._organizer.modList().getMod(mod) + ) + except FileNotFoundError: + pass + + game = self._organizer.managedGame() + if isinstance(game, OblivionRemasteredGame): + if game.ue4ssDirectory().exists(): + for dir_info in game.ue4ssDirectory().entryInfoList( + QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot + ): + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "scripts/main.lua" + ) + ).exists(): + mod_list.add(dir_info.fileName()) + if QFileInfo( + QDir(dir_info.absoluteFilePath()).absoluteFilePath( + "enabled.txt" + ) + ).exists(): + Path(dir_info.absoluteFilePath(), "enabled.txt").unlink() + + final_list = sorted(mod_list, key=cmp_to_key(self.sort_mods)) + self._model.setStringList(final_list) + + def write_mod_list(self): + mod_list: list[UE4SSModInfo] = [] + profile = QDir(self._organizer.profilePath()) + mods_txt = QFileInfo(profile.absoluteFilePath("mods.txt")) + with open(mods_txt.absoluteFilePath(), "w") as txt_file: + for i in range(self._model.rowCount()): + item = self._model.index(i, 0) + name = self._model.data(item, Qt.ItemDataRole.DisplayRole) + active = ( + self._model.data(item, Qt.ItemDataRole.CheckStateRole) + == Qt.CheckState.Checked + ) + mod_list.append({"mod_name": name, "mod_enabled": active}) + txt_file.write(f"{name} : {1 if active else 0}\n") + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + with open(mods_json.absoluteFilePath(), "w") as json_file: + json_file.write(json.dumps(mod_list, indent=4)) + + def sort_mods(self, mod_a: str, mod_b: str) -> int: + profile = QDir(self._organizer.profilePath()) + mods_json = QFileInfo(profile.absoluteFilePath("mods.json")) + mods_list: list[str] = [] + if mods_json.exists() and mods_json.isFile(): + with open(mods_json.absoluteFilePath(), "r") as json_file: + try: + mods = json.load(json_file) + except JSONDecodeError: + mods = DEFAULT_UE4SS_MODS + for mod in mods: + if mod["mod_enabled"]: + mods_list.append(mod["mod_name"]) + index_a = -1 + if mod_a in mods_list: + index_a = mods_list.index(mod_a) + index_b = -1 + if mod_b in mods_list: + index_b = mods_list.index(mod_b) + if index_a != -1 and index_b != -1: + return index_a - index_b + if index_a != -1: + return -1 + if index_b != -1: + return 1 + if mod_a < mod_b: + return -1 + return 1 diff --git a/libs/basic_games/games/quarantine/game_masseffectlegendary.py b/libs/basic_games/games/quarantine/game_masseffectlegendary.py new file mode 100644 index 0000000..fadc250 --- /dev/null +++ b/libs/basic_games/games/quarantine/game_masseffectlegendary.py @@ -0,0 +1,24 @@ +from ...basic_game import BasicGame + + +class MassEffectLegendaryGame(BasicGame): + Name = "Mass Effect Legendary Edition Support Plugin" + Author = "LostDragonist" + Version = "1.0.0" + + GameName = "Mass Effect: Legendary Edition" + GameShortName = "masseffectlegendaryedition" + GameBinary = "Game/Launcher/MassEffectLauncher.exe" + GameLauncher = "MassEffectLauncher.exe" + GameDataPath = "%GAME_PATH%" + GameDocumentsDirectory = ( + "%USERPROFILE%/Documents/BioWare/Mass Effect Legendary Edition/Save" + ) + GameSaveExtension = "pcsav" + GameSteamId = 1328670 + GameOriginWatcherExecutables = ( + "masseffectlauncher.exe", + "masseffect1.exe", + "masseffect2.exe", + "masseffect3.exe", + ) diff --git a/libs/basic_games/games/quarantine/readme.txt b/libs/basic_games/games/quarantine/readme.txt new file mode 100644 index 0000000..7b7cd32 --- /dev/null +++ b/libs/basic_games/games/quarantine/readme.txt @@ -0,0 +1,3 @@ +These are plugins that we want to keep around for various reasons but don't want enabled in a default install. + +Mass Effect: Removed because it only supports like 1% of mods and is meant purely for possible WJ support. diff --git a/libs/basic_games/games/stalkeranomaly/XRIO.py b/libs/basic_games/games/stalkeranomaly/XRIO.py new file mode 100644 index 0000000..870f28d --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRIO.py @@ -0,0 +1,148 @@ +# -*- encoding: utf-8 -*- +from __future__ import annotations + +import io +import struct +from typing import Optional, Tuple + +from .XRMath import IVec3 + + +class XRReader: + def __init__(self, buffer: bytes): + self._buffer = buffer + self._pos = 0 + + def __len__(self) -> int: + return len(self._buffer) + + def _read(self, size: int) -> Tuple[bytes, int]: + pos = min(len(self._buffer), self._pos + size) + buffer = self._buffer[self._pos : pos] + return (buffer, pos) + + def read(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._buffer) + if len(self._buffer) <= self._pos: + return b"" + (buffer, pos) = self._read(size) + self._pos = pos + return buffer + + def peek(self, size: int = -1) -> bytes: + if size < 0: + size = len(self._buffer) + if len(self._buffer) <= self._pos: + return b"" + (buffer, _pos) = self._read(size) + return buffer + + def seek(self, pos: int, whence: int = io.SEEK_SET) -> int: + if whence == 0: + if pos < 0: + raise ValueError(f"negative seek position {pos}") + self._pos = pos + elif whence == 1: + self._pos = max(0, self._pos + pos) + elif whence == 2: + self._pos = max(0, len(self._buffer) + pos) + else: + raise ValueError("unsupported whence value") + return self._pos + + def elapsed(self) -> int: + return len(self._buffer) - self._pos + + def eof(self) -> bool: + return self.elapsed() <= 0 + + def u8(self) -> int: + return int(struct.unpack("<B", self.read(1))[0]) + + def s8(self) -> int: + return int(struct.unpack("<b", self.read(1))[0]) + + def u16(self) -> int: + return int(struct.unpack("<H", self.read(2))[0]) + + def s16(self) -> int: + return int(struct.unpack("<h", self.read(2))[0]) + + def u32(self) -> int: + return int(struct.unpack("<I", self.read(4))[0]) + + def s32(self) -> int: + return int(struct.unpack("<i", self.read(4))[0]) + + def u64(self) -> int: + return int(struct.unpack("<Q", self.read(8))[0]) + + def s64(self) -> int: + return int(struct.unpack("<q", self.read(8))[0]) + + def bool(self) -> bool: + return bool(struct.unpack("<?", self.read(1))[0]) + + def float(self) -> float: + return float(struct.unpack("<f", self.read(4))[0]) + + def str(self) -> str: + chars = bytearray() + while not self.eof(): + c = self.read(1) + if c == b"\x00": + return str(chars, "utf-8") + else: + chars += c + return "" + + def fvec3(self) -> IVec3: + (f1, f2, f3) = struct.unpack("<fff", self.read(12)) + return IVec3(f1, f2, f3) + + +class XRStream(XRReader): + def __init__(self, buffer: bytes): + super().__init__(buffer) + self.last_pos: int = 0 + + def find_chunk(self, id: int) -> Optional[int]: + dw_type = 0 + dw_size = 0 + success = False + if self.last_pos != 0: + self.seek(self.last_pos) + dw_type = self.u32() + dw_size = self.u32() + if (dw_type & (~(1 << 31))) == id: + success = True + + if not success: + self.seek(0) + while not self.eof(): + dw_type = self.u32() + dw_size = self.u32() + if (dw_type & (~(1 << 31))) == id: + success = True + break + else: + self.seek(dw_size, io.SEEK_CUR) + + if not success: + self.last_pos = 0 + return None + + if (self._pos + dw_size) < len(self._buffer): + self.last_pos = self._pos + dw_size + else: + self.last_pos = 0 + + return dw_size + + def open_chunk(self, id: int) -> Optional[XRStream]: + size = self.find_chunk(id) + if size and size != 0: + data = self.read(size) + return XRStream(data) + return None diff --git a/libs/basic_games/games/stalkeranomaly/XRMath.py b/libs/basic_games/games/stalkeranomaly/XRMath.py new file mode 100644 index 0000000..355fcaf --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRMath.py @@ -0,0 +1,37 @@ +class IVec3: + def __init__(self, x: float, y: float, z: float): + self.x = x + self.y = y + self.z = z + + def __str__(self) -> str: + return f"{self.x}, {self.y}, {self.z}" + + +class IVec4(IVec3): + def __init__(self, x: float, y: float, z: float, w: float): + super().__init__(x, y, z) + self.w = w + + def __str__(self) -> str: + return f"{self.x}, {self.y}, {self.z}, f{self.w}" + + +class IFlag: + def __init__(self, flag: int): + self._flag = flag + + def __str__(self) -> str: + return str(self._flag) + + def assign(self, mask: int): + self._flag = mask + + def has(self, mask: int) -> bool: + return bool((self._flag & mask) == mask) + + def set(self, mask: int) -> None: + self._flag |= mask + + def remove(self, mask: int) -> None: + self._flag &= ~mask diff --git a/libs/basic_games/games/stalkeranomaly/XRNET.py b/libs/basic_games/games/stalkeranomaly/XRNET.py new file mode 100644 index 0000000..2df87a9 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRNET.py @@ -0,0 +1,45 @@ +from .XRIO import XRReader +from .XRMath import IVec3, IVec4 + + +class XRNETState: + def __init__(self): + self.position = IVec3(0.0, 0.0, 0.0) + self.quaternion = IVec4(0.0, 0.0, 0.0, 0.0) + self.enabled = False + + def read(self, reader: XRReader, fmin: IVec3, fmax: IVec3): + self.position = self.fvec_q8(reader, fmin, fmax) + self.quaternion = self.fqt_q8(reader) + self.enabled = bool(reader.u8()) + + def clamp(self, val: float, low: float, high: float) -> float: + if val < low: + return low + elif val > high: + return high + else: + return val + + def fvec_q8(self, reader: XRReader, fmin: IVec3, fmax: IVec3): + vec = IVec3(0.0, 0.0, 0.0) + vec.x = self.f_q8(reader, fmin.x, fmax.x) + vec.y = self.f_q8(reader, fmin.y, fmax.y) + vec.z = self.f_q8(reader, fmin.z, fmax.z) + vec.x = self.clamp(vec.x, fmin.x, fmax.x) + vec.y = self.clamp(vec.x, fmin.y, fmax.y) + vec.z = self.clamp(vec.z, fmin.z, fmax.z) + + def fqt_q8(self, reader: XRReader): + vec = IVec4(0.0, 0.0, 0.0, 0.0) + vec.x = self.f_q8(reader, -1.0, 1.0) + vec.y = self.f_q8(reader, -1.0, 1.0) + vec.z = self.f_q8(reader, -1.0, 1.0) + vec.w = self.f_q8(reader, -1.0, 1.0) + vec.x = self.clamp(vec.x, -1.0, 1.0) + vec.y = self.clamp(vec.y, -1.0, 1.0) + vec.z = self.clamp(vec.z, -1.0, 1.0) + vec.w = self.clamp(vec.w, -1.0, 1.0) + + def f_q8(self, reader: XRReader, fmin: float, fmax: float): + return (float(reader.u8()) / 255.0) * (fmax - fmin) + fmin diff --git a/libs/basic_games/games/stalkeranomaly/XRObject.py b/libs/basic_games/games/stalkeranomaly/XRObject.py new file mode 100644 index 0000000..d1eccdb --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRObject.py @@ -0,0 +1,277 @@ +# -*- encoding: utf-8 -*- + +import io +from enum import IntFlag +from typing import List + +from .XRIO import XRReader +from .XRMath import IFlag, IVec3 +from .XRNET import XRNETState + + +class XRFlag(IntFlag): + CHUNK_ALIFE = 0x0 + CHUNK_SPAWN = 0x1 + CHUNK_OBJECT = 0x2 + CHUNK_GAME_TIME = 0x5 + CHUNK_REGISTRY = 0x9 + + SPAWN_VERSION = 1 << 5 + + MSG_UPDATE = 0x0 + MSG_SPAWN = 0x1 + + TRADER_INFINITE_AMMO = 0x1 + + +class XRAbstract: + def __init__(self, name: str = ""): + self._valid = False + self.name = name + self.name_replace = "" + self.rp = 0xFE + self.position = IVec3(0.0, 0.0, 0.0) + self.angle = IVec3(0.0, 0.0, 0.0) + self.respawn_time = 0 + self.id = 0xFFFF + self.id_parent = 0xFFFF + self.id_phantom = 0xFFFF + self.flags = IFlag(0) + self.version = 0 + self.game_type = IFlag(0) + self.script_version = 0 + self.client_data: List[int] = [] + self.spawn_id = 0 + self.ini_str = "" + + def read_spawn(self, reader: XRReader): + spawn = reader.u16() + if spawn != XRFlag.MSG_SPAWN: + self._valid = False + return + self.name = reader.str() + self.name2 = reader.str() + reader.seek(1, io.SEEK_CUR) # temp_gt + self.rp = reader.u8() + self.position = reader.fvec3() + self.angle = reader.fvec3() + self.respawn_time = reader.u16() + self.id = reader.u16() + self.id_parent = reader.u16() + self.id_phantom = reader.u16() + self.flags = IFlag(reader.u16()) + if self.flags.has(XRFlag.SPAWN_VERSION): + self.version = reader.u16() + if self.version == 0: + reader._pos -= 2 # pyright: ignore[reportPrivateUsage] + return + if self.version > 120: + self.game_type.set(reader.u16()) + if self.version > 69: + self.script_version = reader.u16() + if self.version > 70: + cl_size = 0 + if self.version > 93: + cl_size = reader.u16() + else: + cl_size = reader.u8() + if cl_size > 0: + for _ in range(cl_size): + data = reader.u8() + self.client_data.append(data) + if self.version > 79: + self.spawn_id = reader.u16() + self._valid = True + + def __bool__(self): + return self._valid + + +class XRVisual: + def __init__(self): + self.visual_name = "" + self.startup_animation = "" + self.flags = IFlag(0) + + def read_visual(self, reader: XRReader, version: int): + self.visual_name = reader.str() + self.flags = IFlag(reader.u8()) + + +class XRBoneData: + def __init__(self): + self.bones_mask = -1 + self.root_bone = 0 + self.min = IVec3(0.0, 0.0, 0.0) + self.max = IVec3(0.0, 0.0, 0.0) + self.bones: list[XRNETState] = [] + + def load(self, reader: XRReader): + self.bones_mask = reader.u64() + self.root_bone = reader.u16() + self.min = reader.fvec3() + self.max = reader.fvec3() + bones_count = reader.u16() + for _ in range(bones_count): + bone = XRNETState() + bone.read(reader, self.min, self.max) + self.bones.append(bone) + + +class XRSkeleton: + def __init__(self): + self.source_id = -1 + self.saved_bones = XRBoneData() + + def read_state(self, reader: XRReader): + self.visual_animation = reader.str() + flags = IFlag(reader.u8()) + self.source_id = reader.u16() + if flags.has(4): + self.saved_bones.load(reader) + + +class XRObject(XRAbstract): + def __init__(self): + super().__init__() + self.graph_id = 0 + self.distance = 0.0 + self.direct_control = True + self.node_id = 0 + self.story_id = -1 + self.spawn_story_id = -1 + + def read_spawn(self, reader: XRReader): + super().read_spawn(reader) + size = reader.u16() + if size <= 2: + self._valid = False + return + self.read_state(reader) + + def read_state(self, reader: XRReader): + self.graph_id = reader.u16() + self.distance = reader.float() + self.direct_control = bool(reader.u32()) + self.node_id = reader.u32() + self.flags.set(reader.u32()) + self.ini_str = reader.str() + self.story_id = reader.u32() + self.spawn_story_id = reader.u32() + + def read_update(self, reader: XRReader): + update = reader.u16() + if update != XRFlag.MSG_UPDATE: + self._valid = False + return + + +class XRDynamicObject(XRObject): + def __init__(self): + super().__init__() + self.time_id = -1 + self.switch_counter = 0 + + +class XRDynamicObjectVisual(XRDynamicObject, XRVisual): + def read_state(self, reader: XRReader): + XRDynamicObject.read_state(self, reader) + if self.version > 31: + XRVisual.read_visual(self, reader, self.version) + + +class XRCreatureAbstract(XRDynamicObjectVisual): + def __init__(self): + super().__init__() + self.team = 0 + self.squad = 0 + self.group = 0 + self.health = 1.0 + self.dynamic_out: list[int] = [] + self.dynamic_in: list[int] = [] + self.killer_id = -1 + self.death_time = 0 + + def read_state(self, reader: XRReader): + super().read_state(reader) + self.team = reader.u8() + self.squad = reader.u8() + self.group = reader.u8() + self.health = reader.float() * 100 + + for _ in range(reader.u32()): + _id = reader.u16() + self.dynamic_out.append(_id) + + for _ in range(reader.u32()): + _id = reader.u16() + self.dynamic_in.append(_id) + + self.killer_id = reader.u16() + self.death_time = reader.u64() + + +class XRTraderAbstract: + def __init__(self): + self.money = 0 + self.max_item_mass = 0 + self.character_name = "" + self.character_name_str = "" + self.character_profile = "" + self.specific_character = "" + self.community_index = -1 + self.rank = -1 # -MAX_INT? + self.reputation = -1 # -MAX_INT? + self.dead_body_can_take = True + self.dead_body_closed = False + self.trader_flags = IFlag(0) + self.trader_flags.remove(XRFlag.TRADER_INFINITE_AMMO) + + def read_state(self, reader: XRReader): + self.money = reader.u32() + self.specific_character = reader.str() + self.trader_flags.assign(reader.u32()) + self.character_profile = reader.str() + self.community_index = reader.s32() + self.rank = reader.s32() + self.reputation = reader.s32() + self.character_name_str = reader.str() + self.dead_body_can_take = reader.u8() == 1 + self.dead_body_closed = reader.u8() == 1 + + +class XRCreatureActor(XRCreatureAbstract, XRTraderAbstract, XRSkeleton): + def __init__(self): + XRCreatureAbstract.__init__(self) + XRTraderAbstract.__init__(self) + XRSkeleton.__init__(self) + self.state = 0 + self.acceleration = IVec3(0.0, 0.0, 0.0) + self.velocity = IVec3(0.0, 0.0, 0.0) + self.radiation = 0.0 + self.weapon = 0 + self.num_items = 0 + self.holder_id = -1 + + def read_spawn(self, reader: XRReader): + XRCreatureAbstract.read_spawn(self, reader) + + def read_state(self, reader: XRReader): + XRCreatureAbstract.read_state(self, reader) + XRTraderAbstract.read_state(self, reader) + XRSkeleton.read_state(self, reader) + self.holder_id = reader.u16() + + def read_update(self, reader: XRReader): + XRCreatureAbstract.read_update(self, reader) + # XRTraderAbstract.read_update(self, reader) # Future? + self.state = reader.u16() + # acceleration (r_sdir) + reader.seek(2, io.SEEK_CUR) # u16 + reader.seek(4, io.SEEK_CUR) # float + # velocity (r_sdir) + reader.seek(2, io.SEEK_CUR) # u16 + reader.seek(4, io.SEEK_CUR) # float + self.radiation = reader.float() + self.weapon = reader.u8() + self.num_items = reader.u16() diff --git a/libs/basic_games/games/stalkeranomaly/XRSave.py b/libs/basic_games/games/stalkeranomaly/XRSave.py new file mode 100644 index 0000000..b73a988 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/XRSave.py @@ -0,0 +1,164 @@ +import io +import struct +from datetime import datetime +from pathlib import Path +from typing import BinaryIO, Optional, cast + +import lzokay # pyright: ignore[reportMissingTypeStubs] + +from .XRIO import XRReader, XRStream +from .XRObject import XRCreatureActor, XRFlag + + +class XRSave: + filepath: Path + player: XRCreatureActor + + _factions = { + 0: "Loner", + 1: "Monster", + 2: "Trader", + 3: "Army", + 4: "Sin", + 5: "Bandit", + 6: "Duty", + 7: "Ecologist", + 8: "Freedom", + 9: "Mercenary", + 10: "Army", + 11: "Monolith", + 12: "Sin", + 13: "Loner", + 14: "Zombified", + 15: "Clear Sky", + 16: "UNISG", + 17: "Renegade", + 18: "Loner", + 19: "Bandit", + 20: "Duty", + 21: "Freedom", + 22: "Clear Sky", + 23: "Ecologist", + 24: "Mercenary", + 25: "Military", + 26: "Monolith", + 27: "Zombified", + 28: "Sin", + 29: "UNISG", + 30: "Renegade", + 31: "Participant", + } + + _ranks = { + 1999: "Novice", + 3999: "Trainee", + 6999: "Experienced", + 9999: "Professional", + 14999: "Veteran", + 20999: "Expert", + 27999: "Master", + "max": "Legend", + } + + _reputation = { + -2000: "Terrible", + -1500: "Really Bad", + -1000: "Very Bad", + -500: "Bad", + 499: "Netural", + 999: "Good", + 1499: "Very Good", + 1999: "Really Good", + "max": "Excellent", + } + + def __init__(self, filepath: Path): + self.filepath = filepath + self.fetchInfo() + with open(filepath, "rb") as file: + stream = self.readFile(file) + if stream: + self.readObject(stream) + + def fetchInfo(self): + self.splitInfo() + self.time = self.filepath.stat().st_mtime + self.time_date = datetime.fromtimestamp(self.time) + self.time_fmt = self.time_date.strftime("%I:%M %m/%d/%Y") + + def splitInfo(self): + save_clean = self.filepath.name.split(".scop", 1)[0] + save_split = save_clean.split(" - ", 1) + self.user = save_split[0] + if len(save_split) > 1: + save_end = save_split[1].split("_", 1) + if len(save_end) > 1: + self.save_fmt = f"{save_end[0]} (#{save_end[1]})".title() + else: + self.save_fmt = f"{save_end[0]}".title() + else: + self.save_fmt = "Unknown" + + def readFile(self, file: BinaryIO) -> Optional[XRStream]: + size = self.filepath.stat().st_size + if size < 8: + return None + + (start, version, source) = struct.unpack("@iii", file.read(12)) + if (start == -1) and (version >= 6): + file.seek(12) + data = file.read(size - 12) + return XRStream( + cast( + bytes, + lzokay.decompress( # pyright: ignore[reportUnknownMemberType] + data, source + ), + ) + ) + + return None + + def readObject(self, stream: XRStream): + chunk = stream.open_chunk(XRFlag.CHUNK_OBJECT) + if chunk: + chunk.seek(4, io.SEEK_CUR) # obj_count + count_spawn = chunk.u16() + spawn = XRReader(chunk.read(count_spawn)) + actor = XRCreatureActor() + actor.read_spawn(spawn) + count_update = chunk.u16() + update = XRReader(chunk.read(count_update)) + actor.read_update(update) + if actor: + self.player = actor + return None + + def getFaction(self) -> str: + player = self.player + if player: + player_faction = player.community_index + for faction in self._factions: + if player_faction == faction: + return self._factions[faction] + return "Unknown" + + def getRank(self) -> str: + player = self.player + if player: + player_rank = player.rank + for rank in self._ranks: + if isinstance(rank, int): + if player_rank <= rank: + return self._ranks[rank] + return self._ranks["max"] + + def getReputation(self) -> str: + player = self.player + if player: + player_rep = player.reputation + for rep in self._reputation: + if isinstance(rep, int): + if player_rep <= rep: + return self._reputation[rep] + return self._reputation["max"] diff --git a/libs/basic_games/games/stalkeranomaly/__init__.py b/libs/basic_games/games/stalkeranomaly/__init__.py new file mode 100644 index 0000000..a8730a4 --- /dev/null +++ b/libs/basic_games/games/stalkeranomaly/__init__.py @@ -0,0 +1,38 @@ +from .XRIO import XRReader, XRStream +from .XRMath import IFlag, IVec3, IVec4 +from .XRNET import XRNETState +from .XRObject import ( + XRAbstract, + XRBoneData, + XRCreatureAbstract, + XRCreatureActor, + XRDynamicObject, + XRDynamicObjectVisual, + XRFlag, + XRObject, + XRSkeleton, + XRTraderAbstract, + XRVisual, +) +from .XRSave import XRSave + +__all__ = [ + "IFlag", + "IVec3", + "IVec4", + "XRAbstract", + "XRBoneData", + "XRCreatureAbstract", + "XRCreatureActor", + "XRDynamicObject", + "XRDynamicObjectVisual", + "XRFlag", + "XRNETState", + "XRObject", + "XRReader", + "XRSave", + "XRSkeleton", + "XRStream", + "XRTraderAbstract", + "XRVisual", +] diff --git a/libs/basic_games/gog_utils.py b/libs/basic_games/gog_utils.py new file mode 100644 index 0000000..e5e6d24 --- /dev/null +++ b/libs/basic_games/gog_utils.py @@ -0,0 +1,35 @@ +# Code adapted from EzioTheDeadPoet / erri120: +# https://github.com/ModOrganizer2/modorganizer-basic_games/pull/5 + +import winreg +from pathlib import Path + + +def find_games() -> dict[str, Path]: + # List the game IDs from the registry: + game_ids: list[str] = [] + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, r"Software\Wow6432Node\GOG.com\Games" + ) as key: + nkeys = winreg.QueryInfoKey(key)[0] + for ik in range(nkeys): + game_key = winreg.EnumKey(key, ik) + if game_key.isdigit(): + game_ids.append(game_key) + except FileNotFoundError: + return {} + + # For each game, query the path: + games: dict[str, Path] = {} + for game_id in game_ids: + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + f"Software\\Wow6432Node\\GOG.com\\Games\\{game_id}", + ) as key: + games[game_id] = Path(winreg.QueryValueEx(key, "path")[0]) + except FileNotFoundError: + pass + + return games diff --git a/libs/basic_games/origin_utils.py b/libs/basic_games/origin_utils.py new file mode 100644 index 0000000..da050e8 --- /dev/null +++ b/libs/basic_games/origin_utils.py @@ -0,0 +1,104 @@ +# -*- encoding: utf-8 -*- + +# Heavily influenced by https://github.com/erri120/GameFinder + +import os +import threading +import time +from collections.abc import Sequence +from pathlib import Path +from urllib import parse + +import psutil + + +class OriginWatcher: + """ + This is a class to control killing Origin when needed. This is used in + order to hook and unhook Origin to get around the Origin DRM. Support + for launching Origin is not included as it's intended for the game's + DRM to launch Origin as needed. + """ + + def __init__(self, executables: Sequence[str] = []): + self.executables = list(map(lambda s: s.lower(), executables)) + + def spawn_origin_watcher(self) -> bool: + self.kill_origin() + self.worker_alive = True + self.worker = threading.Thread(target=self._workerFunc) + self.worker.start() + return True + + def stop_origin_watcher(self) -> None: + self.worker_alive = False + self.worker.join(10.0) + + def kill_origin(self) -> None: + """ + Kills the Origin application + """ + for proc in psutil.process_iter(): + if proc.name().lower() == "origin.exe": + proc.kill() + + def _workerFunc(self) -> None: + gameAliveCount = 300 # Large number to allow Origin and the game to launch + while self.worker_alive: + gameAlive = False + # See if the game is still alive + for proc in psutil.process_iter(): + if proc.name().lower() in self.executables: + gameAlive = True + break + if gameAlive: + # Game is alive, sleep and keep monitoring at faster pace + gameAliveCount = 5 + else: + gameAliveCount -= 1 + if gameAliveCount <= 0: + self.kill_origin() + self.worker_alive = False + time.sleep(1) + + +def find_games() -> dict[str, Path]: + """ + Find the list of Origin games installed. + + Returns: + A mapping from Origin manifest IDs to install locations for available + Origin games. + """ + games: dict[str, Path] = {} + + program_data_path = os.path.expandvars("%PROGRAMDATA%") + local_content_path = Path(program_data_path).joinpath("Origin", "LocalContent") + for manifest in local_content_path.glob("**/*.mfst"): + # Skip any manifest file with '@steam' + if "@steam" in manifest.name.lower(): + continue + + # Read the file and look for &id= and &dipinstallpath= + with open(manifest, "r") as f: + manifest_query = f.read() + url = parse.urlparse(manifest_query) + query = parse.parse_qs(url.query) + if "id" not in query: + # If id is not present, we have no clue what to do. + continue + if "dipinstallpath" not in query: + # We could query the Origin server for the install location but... no? + continue + + for id_ in query["id"]: + for path_ in query["dipinstallpath"]: + games[id_] = Path(path_) + + return games + + +if __name__ == "__main__": + games = find_games() + for k, v in games.items(): + print("Found game with id {} at {}.".format(k, v)) diff --git a/libs/basic_games/plugin-requirements.txt b/libs/basic_games/plugin-requirements.txt new file mode 100644 index 0000000..f945efd --- /dev/null +++ b/libs/basic_games/plugin-requirements.txt @@ -0,0 +1,4 @@ +psutil==5.8.0 +vdf==3.4 +lzokay==1.1.5 +pyyaml==6.0.2 diff --git a/libs/basic_games/poetry.lock b/libs/basic_games/poetry.lock new file mode 100644 index 0000000..a4066dd --- /dev/null +++ b/libs/basic_games/poetry.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "lzokay" +version = "1.1.5" +description = "Python bindings for LZ👌, a LZO compression/decompression algorithm." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "lzokay-1.1.5-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:4faeefdef8132c4db995de8e96a649c890c63a81cee06b3bbcae9d224302fa09"}, + {file = "lzokay-1.1.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ee2c7144dd3916cf0392d3851cebe8f28136e7998b7a50a91f63d7276327d70"}, + {file = "lzokay-1.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:829bfd081c8f03e85994d920ce7cc8d45033d7c467fc2fadc17b5c47667ec045"}, + {file = "lzokay-1.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d6b1d80121cdbc3cb106b3fafc03b139f3de3c134ce58b38fcc2d751fc5b013"}, + {file = "lzokay-1.1.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f07bdd8c3bd9443d3f5ca69a03b9dc35522c5c92fce61db087ded1f348274f"}, + {file = "lzokay-1.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:266db5697a0d01428e4c31633f8dc1e87666ad17bb46b4c48ec7b7b8b70b94bc"}, + {file = "lzokay-1.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:eec7679194643bc1417f8883ef3c9d27e95e00632d4d2a7dc9ef1a1ca92cdd95"}, + {file = "lzokay-1.1.5-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5426a80664c471c18a47766111629cae5c9435ca8421cd208b00b10de5df5df9"}, + {file = "lzokay-1.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:a4df719d5fef922e0f43ec2408cd70280171ded18ab096c1e88fd1c3ca4dff46"}, + {file = "lzokay-1.1.5-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:88fa389193cde7feb13fd4b57b6b8d626a6159bfe84cd795e664e0a738debb42"}, + {file = "lzokay-1.1.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c83a0f1cc7628237466c298e676a64a1ab4914ccf628b3775f8680477b77481"}, + {file = "lzokay-1.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:f1997c6994239ea24a2c7c7812f9e06bceb6b216c5537b85d8b585dbfd711cb0"}, + {file = "lzokay-1.1.5-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:4389b8b6f3c95aaa33308e2129b026140c850d94440fbea4fdeabcd42477f3b2"}, + {file = "lzokay-1.1.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:867a9fd4a76c830b17366fd2e8bd03fe9b29cf5c13c42bc19562e411cf648dad"}, + {file = "lzokay-1.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:f828864453ddfca036de7470da74245d8dcf369c521291037b1b01ac7c5dbd83"}, + {file = "lzokay-1.1.5.tar.gz", hash = "sha256:3c2e81d178161de58bf233eec87c6ef3dba96fecdd9ba72f8f2c8790adc18f74"}, +] + +[package.extras] +test = ["pytest"] + +[[package]] +name = "mobase-stubs" +version = "2.5.2" +description = "PEP561 stub files for the mobase Python API." +optional = false +python-versions = "<4.0,>=3.12" +groups = ["dev"] +files = [ + {file = "mobase_stubs-2.5.2-py3-none-any.whl", hash = "sha256:6a2c1c95494e7dc0d0f51d4f8f178423c0d9904f823f3c44845315bff304b948"}, + {file = "mobase_stubs-2.5.2.tar.gz", hash = "sha256:70c15579828df3bce01746e51fee07176390cdc386facea2b156ea04b3de055c"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "pastel" +version = "0.2.1" +description = "Bring colors to your terminal." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] +files = [ + {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, + {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, +] + +[[package]] +name = "poethepoet" +version = "0.36.0" +description = "A task runner that works well with poetry and uv." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "poethepoet-0.36.0-py3-none-any.whl", hash = "sha256:693e3c1eae9f6731d3613c3c0c40f747d3c5c68a375beda42e590a63c5623308"}, + {file = "poethepoet-0.36.0.tar.gz", hash = "sha256:2217b49cb4e4c64af0b42ff8c4814b17f02e107d38bc461542517348ede25663"}, +] + +[package.dependencies] +pastel = ">=0.2.1,<0.3.0" +pyyaml = ">=6.0.2,<7.0" + +[package.extras] +poetry-plugin = ["poetry (>=1.2.0,<3.0.0) ; python_version < \"4.0\""] + +[[package]] +name = "psutil" +version = "5.9.8" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +groups = ["main"] +files = [ + {file = "psutil-5.9.8-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:26bd09967ae00920df88e0352a91cff1a78f8d69b3ecabbfe733610c0af486c8"}, + {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:05806de88103b25903dff19bb6692bd2e714ccf9e668d050d144012055cbca73"}, + {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:611052c4bc70432ec770d5d54f64206aa7203a101ec273a0cd82418c86503bb7"}, + {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:50187900d73c1381ba1454cf40308c2bf6f34268518b3f36a9b663ca87e65e36"}, + {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:02615ed8c5ea222323408ceba16c60e99c3f91639b07da6373fb7e6539abc56d"}, + {file = "psutil-5.9.8-cp27-none-win32.whl", hash = "sha256:36f435891adb138ed3c9e58c6af3e2e6ca9ac2f365efe1f9cfef2794e6c93b4e"}, + {file = "psutil-5.9.8-cp27-none-win_amd64.whl", hash = "sha256:bd1184ceb3f87651a67b2708d4c3338e9b10c5df903f2e3776b62303b26cb631"}, + {file = "psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81"}, + {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421"}, + {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4"}, + {file = "psutil-5.9.8-cp36-cp36m-win32.whl", hash = "sha256:7d79560ad97af658a0f6adfef8b834b53f64746d45b403f225b85c5c2c140eee"}, + {file = "psutil-5.9.8-cp36-cp36m-win_amd64.whl", hash = "sha256:27cc40c3493bb10de1be4b3f07cae4c010ce715290a5be22b98493509c6299e2"}, + {file = "psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0"}, + {file = "psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf"}, + {file = "psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8"}, + {file = "psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c"}, +] + +[package.extras] +test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""] + +[[package]] +name = "pyqt6" +version = "6.7.0" +description = "Python bindings for the Qt cross platform application toolkit" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyQt6-6.7.0-1-cp38-abi3-macosx_10_14_universal2.whl", hash = "sha256:656734112853fde1be0963f0ad362e5efd87ba6c6ff234cb1f9fe8003ee254e6"}, + {file = "PyQt6-6.7.0-1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fa2d27fc2f5340f3f1e145c815101ef4550771a9e4bfafd4c7c2479fe83d9488"}, + {file = "PyQt6-6.7.0-1-cp38-abi3-win_amd64.whl", hash = "sha256:6a1f6dfe03752f888b5e628c208f9fd1a03bda7ebda59ffed8c13580289a1892"}, + {file = "PyQt6-6.7.0-cp38-abi3-macosx_10_14_universal2.whl", hash = "sha256:919ffb01020ece42209228bf94b4f2c156a6b77cc5a69a90a05e358b0333750b"}, + {file = "PyQt6-6.7.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e294f025f94493ee12b66efd6893fab309c9063172bb8a5b184f84dfc1ebcc49"}, + {file = "PyQt6-6.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:9d8865fb6357dba032002c4554a9648e88f2b4706c929cc51fba58edafad91fc"}, + {file = "PyQt6-6.7.0.tar.gz", hash = "sha256:3d31b2c59dc378ee26e16586d9469842483588142fc377280aad22aaf2fa6235"}, +] + +[package.dependencies] +PyQt6-Qt6 = ">=6.7.0,<6.8.0" +PyQt6-sip = ">=13.6,<14" + +[[package]] +name = "pyqt6-qt6" +version = "6.7.3" +description = "The subset of a Qt installation needed by PyQt6." +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "PyQt6_Qt6-6.7.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:f517a93b6b1a814d4aa6587adc312e812ebaf4d70415bb15cfb44268c5ad3f5f"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8551732984fb36a5f4f3db51eafc4e8e6caf18617365830285306f2db17a94c2"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:50c7482bcdcf2bb78af257fb10ed8b582f8daf91d829782393bc50ac5a0a900c"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb525fdd393332de60887953029276a44de480fce1d785251ae639580f5e7246"}, + {file = "PyQt6_Qt6-6.7.3-py3-none-win_amd64.whl", hash = "sha256:36ea0892b8caeb983af3f285f45fb8dfbb93cfd972439f4e01b7efb2868f6230"}, +] + +[[package]] +name = "pyqt6-sip" +version = "13.10.0" +description = "The sip module support for PyQt6" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "PyQt6_sip-13.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7b1258963717cfae1d30e262bb784db808072a8a674d98f57c2076caaa50499"}, + {file = "PyQt6_sip-13.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d27a3fed2a461f179d3cde6a74530fbad629ccaa66ed739b9544fda1932887af"}, + {file = "PyQt6_sip-13.10.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0422781c77b85eefd7a26f104c5998ede178a16b0fd35212664250215b6e5e4c"}, + {file = "PyQt6_sip-13.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:f64183dde2af36515dab515f4301a5a8d9b3658b231769fa48fe6287dc52f375"}, + {file = "PyQt6_sip-13.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e78fb8036b18f6258a1af0956c5a3cec1dd3d8dd5196ecd89a31b529bf40e82"}, + {file = "PyQt6_sip-13.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e19d5887fa3003a635419644dfed3158cb15eb566fc27b1ed56913a5767a71dc"}, + {file = "PyQt6_sip-13.10.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079bb946edc3960f08d92b3a8eebff55d3abb51bc2a0583b6683dfd9f77a616a"}, + {file = "PyQt6_sip-13.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:90974f5dbba1f5d1d2ca9b1cfdfd5258e5e3cfacead03f0df674d54c69973ea7"}, + {file = "PyQt6_sip-13.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:bbefd5539eeda4dec37e8b6dfc362ba240ec31279060336bcceaff572807dac8"}, + {file = "PyQt6_sip-13.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:48791db2914fc39c3218519a02d2a5fd3fcd354a1be3141a57bf2880701486f2"}, + {file = "PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:466d6b4791973c9fcbdc2e0087ed194b9ea802a8c3948867a849498f0841c70c"}, + {file = "PyQt6_sip-13.10.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ae15358941f127cd3d1ab09c1ebd45c4dabb0b2e91587b9eebde0279d0039c54"}, + {file = "PyQt6_sip-13.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad573184fa8b00041944e5a17d150ab0d08db2d2189e39c9373574ebab3f2e58"}, + {file = "PyQt6_sip-13.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d579d810d0047d40bde9c6aef281d6ed218db93c9496ebc9e55b9e6f27a229d"}, + {file = "PyQt6_sip-13.10.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b6e250c2e7c14702a623f2cc1479d7fb8db2b6eee9697cac10d06fe79c281bb"}, + {file = "PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fcb30756568f8cd59290f9ef2ae5ee3e72ff9cdd61a6f80c9e3d3b95ae676be"}, + {file = "PyQt6_sip-13.10.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:757ac52c92b2ef0b56ecc7cd763b55a62d3c14271d7ea8d03315af85a70090ff"}, + {file = "PyQt6_sip-13.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:571900c44a3e38738d696234d94fe2043972b9de0633505451c99e2922cb6a34"}, + {file = "PyQt6_sip-13.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:39cba2cc71cf80a99b4dc8147b43508d4716e128f9fb99f5eb5860a37f082282"}, + {file = "PyQt6_sip-13.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f026a1278f9c2a745542d4a05350f2392d4cf339275fb8efccb47b0f213d120"}, + {file = "PyQt6_sip-13.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:548c70bc40d993be0eb011e1bbc41ba7c95f6af375613b58217f39ad8d703345"}, + {file = "PyQt6_sip-13.10.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21417ffd2c489afef114cb09683bbc0fb24d78df848a21fc0d09e70ecbb0a4a4"}, + {file = "PyQt6_sip-13.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:6e1b1f7a29290afc83bcd9970e0cffa2d0da87d81796b6eab7b6f583e4f49652"}, + {file = "pyqt6_sip-13.10.0.tar.gz", hash = "sha256:d6daa95a0bd315d9ec523b549e0ce97455f61ded65d5eafecd83ed2aa4ae5350"}, +] + +[[package]] +name = "pyright" +version = "1.1.402" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982"}, + {file = "pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "ruff" +version = "0.12.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be"}, + {file = "ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e"}, + {file = "ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342"}, + {file = "ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a"}, + {file = "ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639"}, + {file = "ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12"}, + {file = "ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e"}, +] + +[[package]] +name = "types-psutil" +version = "5.9.5.20240516" +description = "Typing stubs for psutil" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "types-psutil-5.9.5.20240516.tar.gz", hash = "sha256:bb296f59fc56458891d0feb1994717e548a1bcf89936a2877df8792b822b4696"}, + {file = "types_psutil-5.9.5.20240516-py3-none-any.whl", hash = "sha256:83146ded949a10167d9895e567b3b71e53ebc5e23fd8363eab62b3c76cce7b89"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "vdf" +version = "3.4" +description = "Library for working with Valve's VDF text format" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "vdf-3.4-py2.py3-none-any.whl", hash = "sha256:68c1a125cc49e343d535af2dd25074e9cb0908c6607f073947c4a04bbe234534"}, + {file = "vdf-3.4.tar.gz", hash = "sha256:fd5419f41e07a1009e5ffd027c7dcbe43d1f7e8ef453aeaa90d9d04b807de2af"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12,<4.0" +content-hash = "b3a65da37845a56d4b259c4d73913b8957a61382499eeda8bdce2343a48e8c66" diff --git a/libs/basic_games/pyproject.toml b/libs/basic_games/pyproject.toml new file mode 100644 index 0000000..89778ca --- /dev/null +++ b/libs/basic_games/pyproject.toml @@ -0,0 +1,59 @@ +# note: this file is for local development, currently the file is not used to build +# the plugin +[project] +name = "basic-games" +version = "0.1.0" +description = "" +authors = [] +license = "MIT" +readme = "README.md" +requires-python = ">=3.12,<4.0" +dynamic = ["dependencies"] + +[tool.poetry] +package-mode = false + +[tool.poetry.dependencies] +psutil = "^5.8.0" +vdf = "3.4" +lzokay = "1.1.5" +pyqt6 = "6.7.0" +pyyaml = "^6.0.2" + +[tool.poetry.group.dev.dependencies] +mobase-stubs = "^2.5.2" +pyqt6 = "^6.7.0" +pyright = "^1.1.402" +ruff = "^0.12.2" +types-psutil = "<6" +poethepoet = "^0.36.0" + +[build-system] +requires = ["poetry-core (>=2.0)"] +build-backend = "poetry.core.masonry.api" + +[tool.poe.tasks] +format-imports = "ruff check --select I . --fix" +format-ruff = "ruff format ." +format.sequence = ["format-imports", "format-ruff"] +lint-ruff = "ruff check ." +lint-ruff-format = "ruff format --check ." +lint-pyright = "pyright ." +lint.sequence = ["lint-ruff", "lint-ruff-format", "lint-pyright"] +lint.ignore_fail = "return_non_zero" + +[tool.ruff.lint] +extend-select = ["B", "Q", "I"] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["PyQt6.QtCore.QModelIndex"] + +[tool.ruff.lint.isort] +known-first-party = ["mobase"] + +[tool.pyright] +exclude = ["lib", "**/.*", "venv"] +typeCheckingMode = "strict" +reportMissingTypeStubs = true +reportMissingModuleSource = false +pythonPlatform = "Windows" diff --git a/libs/basic_games/steam_utils.py b/libs/basic_games/steam_utils.py new file mode 100644 index 0000000..d1d4a06 --- /dev/null +++ b/libs/basic_games/steam_utils.py @@ -0,0 +1,187 @@ +# Code greatly inspired by https://github.com/LostDragonist/steam-library-setup-tool + +import sys +import winreg +from pathlib import Path +from typing import TypedDict, cast + +import vdf # pyright: ignore[reportMissingTypeStubs] + + +class SteamGame: + def __init__(self, appid: str, installdir: str): + self.appid = appid + self.installdir = installdir + + def __repr__(self): + return str(self) + + def __str__(self): + return "{} ({})".format(self.appid, self.installdir) + + +class _AppState(TypedDict): + appid: str + installdir: str + + +class _AppManifest(TypedDict): + AppState: _AppState + + +class _LibraryFolder(TypedDict): + path: str + + +class _LibraryFolders(TypedDict, total=False): + libraryfolders: dict[str, _LibraryFolder] + LibraryFolders: dict[str, str] + + +class LibraryFolder: + def __init__(self, path: Path): + self.path = path + + self.games: list[SteamGame] = [] + for filepath in path.joinpath("steamapps").glob("appmanifest_*.acf"): + try: + with open(filepath, "r", encoding="utf-8") as fp: + info = cast( + _AppManifest, + vdf.load(fp), # pyright: ignore[reportUnknownMemberType] + ) + app_state = info["AppState"] + except KeyError: + print( + f'Unable to read application state from "{filepath}"', + file=sys.stderr, + ) + continue + except Exception as e: + print(f'Unable to parse file "{filepath}": {e}', file=sys.stderr) + continue + + try: + app_id = app_state["appid"] + install_dir = app_state["installdir"] + self.games.append(SteamGame(app_id, install_dir)) + except KeyError: + print( + f"Unable to read application ID or installation folder " + f'from "{filepath}"', + file=sys.stderr, + ) + continue + + def __repr__(self): + return str(self) + + def __str__(self): + return "LibraryFolder at {}: {}".format(self.path, self.games) + + +def parse_library_info(library_vdf_path: Path) -> list[LibraryFolder]: + """ + Read library folders from the main library file. + + Args: + library_vdf_path: The main library file (from the Steam installation + folder). + + Returns: + A list of LibraryFolder, for each library found. + """ + + with open(library_vdf_path, "r", encoding="utf-8") as f: + info = cast( + _LibraryFolders, + vdf.load(f), # pyright: ignore[reportUnknownMemberType] + ) + + info_folders: dict[str, str] | dict[str, _LibraryFolder] + + if "libraryfolders" in info: + # new format + info_folders = info["libraryfolders"] + + elif "LibraryFolders" in info: + # old format + info_folders = info["LibraryFolders"] + + else: + raise ValueError(f'Unknown file format from "{library_vdf_path}"') + + library_folders: list[LibraryFolder] = [] + + for key, value in info_folders.items(): + # only keys that are integer values contains library folder + try: + int(key) + except ValueError: + continue + + if isinstance(value, str): + path = value + else: + path = value["path"] + + try: + library_folders.append(LibraryFolder(Path(path))) + except Exception as e: + print( + 'Failed to read steam library from "{}", {}'.format(path, repr(e)), + file=sys.stderr, + ) + + return library_folders + + +def find_steam_path() -> Path | None: + """ + Retrieve the Steam path, if available. + + Returns: + The Steam path, or None if Steam is not installed. + """ + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Software\\Valve\\Steam") as key: + value = winreg.QueryValueEx(key, "SteamExe") + return Path(value[0].replace("/", "\\")).parent + except FileNotFoundError: + return None + + +def find_games() -> dict[str, Path]: + """ + Find the list of Steam games installed. + + Returns: + A mapping from Steam game ID to install locations for available + Steam games. + """ + steam_path = find_steam_path() + if not steam_path: + return {} + + library_vdf_path = steam_path.joinpath("steamapps", "libraryfolders.vdf") + + try: + library_folders = parse_library_info(library_vdf_path) + library_folders.append(LibraryFolder(steam_path)) + except FileNotFoundError: + return {} + + games: dict[str, Path] = {} + for library in library_folders: + for game in library.games: + games[game.appid] = Path(library.path).joinpath( + "steamapps", "common", game.installdir + ) + + return games + + +if __name__ == "__main__": + games = find_games() + for k, v in games.items(): + print("Found game with id {} at {}.".format(k, v)) diff --git a/libs/basic_games/vcpkg.json b/libs/basic_games/vcpkg.json new file mode 100644 index 0000000..a27b7c5 --- /dev/null +++ b/libs/basic_games/vcpkg.json @@ -0,0 +1,17 @@ +{ + "features": { + "standalone": { + "description": "Build Standalone.", + "dependencies": [ + "mo2-cmake" + ] + } + }, + "vcpkg-configuration": { + "default-registry": { + "kind": "git", + "repository": "https://github.com/ModOrganizer2/vcpkg-registry", + "baseline": "69db61b22147b14ad66fd05cf798d855f6d68c12" + } + } +} diff --git a/libs/basic_games/winreg.py b/libs/basic_games/winreg.py new file mode 100644 index 0000000..e1303dc --- /dev/null +++ b/libs/basic_games/winreg.py @@ -0,0 +1,14 @@ +# Linux compatibility shim for Python plugins that import winreg. +# These plugins already handle FileNotFoundError fallback paths when registry +# lookups are unavailable. + +HKEY_LOCAL_MACHINE = object() +HKEY_CURRENT_USER = object() + + +def OpenKey(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") + + +def QueryValueEx(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") |
