mirror of
https://github.com/ModOrganizer2/modorganizer-basic_games.git
synced 2026-07-27 14:07:29 -07:00
246 lines
8.0 KiB
Python
246 lines
8.0 KiB
Python
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 = 1450711444
|
|
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]
|