aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games/games/game_darkestdungeon.py
diff options
context:
space:
mode:
Diffstat (limited to 'libs/basic_games/games/game_darkestdungeon.py')
-rw-r--r--libs/basic_games/games/game_darkestdungeon.py245
1 files changed, 245 insertions, 0 deletions
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]