forked from ModOrganizer2/modorganizer-basic_games
-
Notifications
You must be signed in to change notification settings - Fork 1
/
game_darkestdungeon.py
236 lines (207 loc) · 7.59 KB
/
game_darkestdungeon.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
import json
from pathlib import Path
import mobase
from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths
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]