This commit is contained in:
Qudix
2021-03-02 17:36:51 -06:00
10 changed files with 317 additions and 45 deletions
+1
View File
@@ -2,6 +2,7 @@
__pycache__
.tox
.vscode
.idea
# Mob / Umbrella
mob.log
+1
View File
@@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.16)
project(basic_games LANGUAGES NONE)
set(project_type python_module_plugin)
set(enable_warnings OFF)
set(create_translations OFF)
if(DEFINED DEPENDENCIES_DIR)
include(${DEPENDENCIES_DIR}/modorganizer_super/cmake_common/project.cmake)
+4 -4
View File
@@ -4,7 +4,7 @@ Mod Organizer 2 meta-plugin to make creating game plugins easier and faster.
## Why?
In order to create a MO2 game plugin, one must implements the `IPluginGame` interface.
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.
@@ -48,14 +48,14 @@ You can rename `modorganizer-basic_games-xxx` to whatever you want (e.g., `basic
| 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>|
| 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) | |
| Dark Messiah of Might & Magic — [STEAM](https://store.steampowered.com/app/2100/Dark_Messiah_of_Might__Magic/) | [Holt59](https://github.com/holt59/) | [game_darkmessiah[...].py](games/game_darkmessiahofmightandmagic.py) | <ul><li>save game preview</li></ul> |
| 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) | |
| 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/)| [Silencer711](https://github.com/Silencer711) | [game_kingdomcomedeliverance.py](games/game_kingdomcomedeliverance.py) | <ul><li>profile specific cfg files</li></ul>|
| 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> |
|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)| |
| 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> |
| 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> |
+2 -13
View File
@@ -3,7 +3,7 @@
import sys
from pathlib import Path
from typing import Callable, List, Optional, Type
from typing import Callable, List, Optional
from PyQt5.QtCore import QDateTime, Qt
from PyQt5.QtGui import QPixmap, QImage
@@ -88,17 +88,9 @@ class BasicGameSaveGameInfoWidget(mobase.ISaveGameInfoWidget):
class BasicGameSaveGameInfo(mobase.SaveGameInfo):
def __init__(
self,
get_preview: Optional[Callable[[str], str]] = None,
savegame_class: Type[BasicGameSaveGame] = BasicGameSaveGame,
):
def __init__(self, get_preview: Optional[Callable[[str], str]] = None):
super().__init__()
self._get_preview = get_preview
self._savegame_class = savegame_class
def getSaveGameInfo(self, filename: str):
return self._savegame_class(Path(filename))
def getMissingAssets(self, save: mobase.ISaveGame):
return {}
@@ -107,6 +99,3 @@ class BasicGameSaveGameInfo(mobase.SaveGameInfo):
if self._get_preview is not None:
return BasicGameSaveGameInfoWidget(parent, self._get_preview)
return None
def hasScriptExtenderSave(self, filename: str):
return False
+16
View File
@@ -157,6 +157,15 @@ class BasicGameOptionsMapping(BasicGameMapping[List[T]]):
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
@@ -335,6 +344,13 @@ class BasicGame(mobase.IPluginGame):
self._mappings: BasicGameMappings = BasicGameMappings(self)
# 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()
# IPlugin interface:
def init(self, organizer: mobase.IOrganizer) -> bool:
+200 -7
View File
@@ -1,16 +1,167 @@
# -*- encoding: utf-8 -*-
from pathlib import Path
from typing import List
import json
from PyQt5.QtCore import QDir, QFileInfo, QStandardPaths
import mobase
from ..basic_game import BasicGame
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, tree: mobase.IFileTree
) -> mobase.ModDataChecker.CheckReturn:
for entry in tree:
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):
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()
for x 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.1.1"
Version = "0.2.0"
GameName = "Darkest Dungeon"
GameShortName = "darkestdungeon"
@@ -21,17 +172,59 @@ class DarkestDungeonGame(BasicGame):
GameBinary = "_windowsnosteam//darkest.exe"
GameDataPath = ""
def init(self, organizer: mobase.IOrganizer) -> bool:
super().init(organizer)
self._featureMap[mobase.ModDataChecker] = DarkestDungeonModDataChecker()
return True
def executables(self):
path = QFileInfo(self.gameDirectory(), "_windows/darkest.exe")
if not path.exists():
if self.is_steam():
path = QFileInfo(self.gameDirectory(), "_windows/darkest.exe")
else:
path = QFileInfo(self.gameDirectory(), "_windowsnosteam/darkest.exe")
return [
mobase.ExecutableInfo("Darkest Dungeon", path),
mobase.ExecutableInfo("Darkest Dungeon", path).withWorkingDirectory(
self.gameDirectory()
),
]
def savesDirectory(self):
return QDir(
@staticmethod
def getCloudSaveDirectory():
steamPath = Path(find_steam_path())
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 = QDir(
"{}/Darkest".format(
QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation)
)
)
if self.is_steam():
cloudSaves = self.getCloudSaveDirectory()
if cloudSaves is None:
return documentsSaves
return QDir(cloudSaves)
return documentsSaves
def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]:
profiles = list()
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]
+15
View File
@@ -0,0 +1,15 @@
from ..basic_game import BasicGame
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"
+20 -3
View File
@@ -1,18 +1,35 @@
# -*- encoding: utf-8 -*-
from PyQt5.QtCore import QFileInfo
import mobase
from ..basic_game import BasicGame
class NoMansSkyGame(BasicGame):
Name = "Mo Man's Sky Support Plugin"
Name = "No Man's Sky Support Plugin"
Author = "Luca/EzioTheDeadPoet"
Version = "1.0.0"
GameName = "Mo Man's Sky"
GameName = "No Man's Sky"
GameShortName = "nomanssky"
GaneNexusHame = "nomanssky"
GameNexusName = "nomanssky"
GameNexusId = 1634
GameSteamId = 275850
GameGogId = 1446213994
GameBinary = "Binaries/NMS.exe"
GameDataPath = "GAMEDATA/PCBANKS/MODS"
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"),
]
+12 -1
View File
@@ -2,6 +2,10 @@
import mobase
from pathlib import Path
from typing import List
from PyQt5.QtCore import QDir
from ..basic_game import BasicGame
from ..basic_features.basic_save_game_info import BasicGameSaveGame
@@ -34,9 +38,16 @@ class Witcher3Game(BasicGame):
def init(self, organizer: mobase.IOrganizer):
super().init(organizer)
self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo(
lambda s: s.replace(".sav", ".png"), Witcher3SaveGame
lambda s: s.replace(".sav", ".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}")
]
+46 -17
View File
@@ -2,12 +2,11 @@
# Code greatly inspired by https://github.com/LostDragonist/steam-library-setup-tool
import os
import sys
import winreg # type: ignore
from pathlib import Path
from typing import Dict
from typing import Dict, List, Optional
class SteamGame:
@@ -23,13 +22,12 @@ class SteamGame:
class LibraryFolder:
def __init__(self, path: str):
def __init__(self, path: Path):
self.path = path
self.games = []
for filename in os.listdir(os.path.join(path, "steamapps")):
if filename.startswith("appmanifest"):
filepath = os.path.join(path, "steamapps", filename)
for filepath in path.joinpath("steamapps").iterdir():
if filepath.name.startswith("appmanifest") and filepath.is_file():
try:
with open(filepath, "r", encoding="utf-8") as fp:
i, n = None, None
@@ -56,7 +54,17 @@ class LibraryFolder:
return "LibraryFolder at {}: {}".format(self.path, self.games)
def parse_library_info(library_vdf_path):
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.
"""
library_folders = []
@@ -92,30 +100,51 @@ def parse_library_info(library_vdf_path):
continue
try:
library_folders.append(
LibraryFolder(parts[2].strip().replace("\\\\", "\\"))
path = parts[2].strip().replace("\\\\", "\\")
library_folders.append(LibraryFolder(Path(path)))
except Exception as e:
print(
'Failed to read steam library from "{}", {}'.format(
path, repr(e)
),
file=sys.stderr,
)
except (FileNotFoundError, ValueError):
continue
return library_folders
def find_games() -> Dict[str, Path]:
def find_steam_path() -> Optional[Path]:
"""
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")
steam_path = value[0].replace("/", "\\")
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 = os.path.join(
os.path.dirname(steam_path), "steamapps", "libraryfolders.vdf"
)
library_vdf_path = steam_path.joinpath("steamapps", "libraryfolders.vdf")
try:
library_folders = parse_library_info(library_vdf_path)
library_folders.append(LibraryFolder(os.path.dirname(steam_path)))
library_folders.append(LibraryFolder(steam_path))
except FileNotFoundError:
return {}