Files
modorganizer-basic_games/games/game_darkestdungeon.py
Mikaël Capelle f502fb468f Add pre-commit hook (#179)
* Update dev dependencies.
* Add pre-commit hook.
2025-04-25 08:23:10 +02:00

238 lines
7.6 KiB
Python

import json
from pathlib import Path
from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths
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 = "_windowsnosteam//darkest.exe"
GameDataPath = ""
GameSupportURL = (
r"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):
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).withWorkingDirectory(
self.gameDirectory()
),
]
@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 = QDir(
"{}/Darkest".format(
QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.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[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]