mirror of
https://github.com/ModOrganizer2/modorganizer-basic_games.git
synced 2026-07-27 14:07:29 -07:00
Merge branch 'erri120-dd-fix'
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
__pycache__
|
||||
.tox
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Mob / Umbrella
|
||||
mob.log
|
||||
|
||||
@@ -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></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> |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,16 +1,117 @@
|
||||
# -*- 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 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"
|
||||
@@ -22,16 +123,53 @@ class DarkestDungeonGame(BasicGame):
|
||||
GameDataPath = ""
|
||||
|
||||
def executables(self):
|
||||
path = QFileInfo(self.gameDirectory(), "_windows/darkest.exe")
|
||||
if not path.exists():
|
||||
if self.isSteam():
|
||||
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]
|
||||
|
||||
+24
-7
@@ -7,7 +7,7 @@ import sys
|
||||
import winreg # type: ignore
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class SteamGame:
|
||||
@@ -105,21 +105,38 @@ def parse_library_info(library_vdf_path):
|
||||
return library_folders
|
||||
|
||||
|
||||
def find_games() -> Dict[str, Path]:
|
||||
def find_steam_path() -> Optional[str]:
|
||||
"""
|
||||
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 str(os.path.dirname(value[0].replace("/", "\\")))
|
||||
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 = os.path.join(steam_path, "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 {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user