mirror of
https://github.com/ModOrganizer2/modorganizer-basic_games.git
synced 2026-07-27 14:07:29 -07:00
342 lines
11 KiB
Python
342 lines
11 KiB
Python
import datetime
|
|
import os
|
|
import struct
|
|
import time
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import BinaryIO
|
|
|
|
from PyQt6.QtCore import QDateTime, QDir, QFile, QFileInfo
|
|
|
|
import mobase
|
|
|
|
from ..basic_features import BasicLocalSavegames
|
|
from ..basic_features.basic_save_game_info import (
|
|
BasicGameSaveGame,
|
|
BasicGameSaveGameInfo,
|
|
format_date,
|
|
)
|
|
from ..basic_game import BasicGame
|
|
|
|
|
|
class BlackAndWhite2ModDataChecker(mobase.ModDataChecker):
|
|
_validFolderTree = {
|
|
"<black & white 2>": ["audio", "data", "plugins", "scripts"],
|
|
"audio": ["dialogue", "music", "sfx"],
|
|
"music": [
|
|
"buildingmusic",
|
|
"chant",
|
|
"cutscene",
|
|
"dynamic music",
|
|
"epicspell",
|
|
"townalignment",
|
|
],
|
|
"sfx": ["atmos", "creature", "game", "script", "spells", "video", "grass"],
|
|
"data": [
|
|
"art",
|
|
"balance",
|
|
"ctr",
|
|
"effects",
|
|
"encryptedshaders",
|
|
"font",
|
|
"handdemo",
|
|
"interface",
|
|
"landscape",
|
|
"light particle effects",
|
|
"lipsync",
|
|
"physics",
|
|
"sfx",
|
|
"shaders",
|
|
"symbols",
|
|
"text",
|
|
"textures",
|
|
"tutorial avi",
|
|
"visualeffects",
|
|
"weathersystem",
|
|
"zones",
|
|
],
|
|
"art": [
|
|
"binary_anim_libs",
|
|
"binary_animations",
|
|
"features",
|
|
"models",
|
|
"skins",
|
|
"textures",
|
|
"water",
|
|
],
|
|
"ctr": [
|
|
"badvisor_evil",
|
|
"badvisor_good",
|
|
"bape",
|
|
"bgorilla",
|
|
"bhand",
|
|
"blion",
|
|
"btiger",
|
|
"bwolf",
|
|
"damage",
|
|
"siren",
|
|
],
|
|
"font": ["asian"],
|
|
"asian": ["korean", "traditional chinese"],
|
|
"landscape": [
|
|
"aztec",
|
|
"bw2",
|
|
"egyptian",
|
|
"generic",
|
|
"greek",
|
|
"japanese",
|
|
"norse",
|
|
"skysettings",
|
|
],
|
|
"tutorial avi": ["placeholder", "stills"],
|
|
"visualeffects": ["textures"],
|
|
"scripts": ["bw2"],
|
|
}
|
|
_validFileLocation = {
|
|
"<black & white 2>": ["exe", "dll", "ico", "png", "jpeg", "jpg"]
|
|
}
|
|
_mapFile = ["chl", "bmp", "bwe", "ter", "pat", "xml", "wal", "txt"]
|
|
_fileIgnore = ["readme", "read me", "meta.ini", "thumbs.db", "backup", ".png"]
|
|
|
|
def fix(self, filetree: mobase.IFileTree):
|
|
toMove: list[tuple[mobase.FileTreeEntry, str]] = []
|
|
for entry in filetree:
|
|
if any([sub in entry.name().casefold() for sub in self._fileIgnore]):
|
|
continue
|
|
elif entry.suffix() == "chl":
|
|
toMove.append((entry, "/Scripts/BW2/"))
|
|
elif entry.suffix() == "bmp":
|
|
toMove.append((entry, "/Data/"))
|
|
elif entry.suffix() == "txt":
|
|
toMove.append((entry, "/Scripts/"))
|
|
else:
|
|
toMove.append((entry, "/Data/landscape/BW2/"))
|
|
|
|
for entry, path in toMove:
|
|
filetree.move(entry, path, policy=mobase.IFileTree.MERGE)
|
|
|
|
return filetree
|
|
|
|
def dataLooksValid(
|
|
self, filetree: mobase.IFileTree
|
|
) -> mobase.ModDataChecker.CheckReturn:
|
|
# qInfo("Data validation start")
|
|
root = filetree
|
|
unpackagedMap = False
|
|
|
|
for entry in filetree:
|
|
entryName = entry.name().casefold()
|
|
canIgnore = any([sub in entryName for sub in self._fileIgnore])
|
|
if not canIgnore:
|
|
parent = entry.parent()
|
|
if parent is not None:
|
|
if parent != root:
|
|
parentName = parent.name().casefold()
|
|
else:
|
|
# qInfo(str(entryName))
|
|
parentName = "<black & white 2>"
|
|
|
|
if not entry.isDir():
|
|
if parentName in self._validFileLocation.keys():
|
|
if (
|
|
entry.suffix()
|
|
not in self._validFileLocation[parentName]
|
|
):
|
|
if (
|
|
entry.suffix() in self._mapFile
|
|
or entryName == "map.txt"
|
|
):
|
|
unpackagedMap = True
|
|
else:
|
|
return mobase.ModDataChecker.INVALID
|
|
else:
|
|
unpackagedMap = False
|
|
if parentName in self._validFolderTree.keys():
|
|
if entryName not in self._validFolderTree[parentName]:
|
|
return mobase.ModDataChecker.INVALID
|
|
|
|
# qInfo(str(unpackagedMap))
|
|
if unpackagedMap:
|
|
return mobase.ModDataChecker.FIXABLE
|
|
else:
|
|
return mobase.ModDataChecker.VALID
|
|
|
|
|
|
class BlackAndWhite2SaveGame(BasicGameSaveGame):
|
|
_saveInfLayout = {
|
|
"start": [0x00000000, 0x00000004],
|
|
"name": [0x00000004, 0x0000002C],
|
|
"empty": [0x0000002C, 0x00000104],
|
|
"land": [0x00000104, 0x00000108],
|
|
"date": [0x00000108, 0x00000110],
|
|
"empty1": [0x00000110, 0x00000114],
|
|
"elapsed": [0x00000114, 0x00000118],
|
|
"empty2": [0x00000108, 0x0000011C],
|
|
}
|
|
|
|
def __init__(self, filepath: Path):
|
|
super().__init__(filepath)
|
|
self._filepath = Path(filepath)
|
|
self.name: str = ""
|
|
self.land: int = -1
|
|
self.elapsed: int = 0
|
|
self.lastsave: int = 0
|
|
with open(self._filepath.joinpath("SaveGame.inf"), "rb") as info:
|
|
# Name embedded in "SaveGame.inf" with UTF-16 encoding
|
|
self.name = self.readInf(info, "name").decode("utf-16")
|
|
# Land number embedded in "SaveGame.inf" as an int written in binary
|
|
self.land = int.from_bytes(self.readInf(info, "land"), "little")
|
|
# Getting elapsed time in second
|
|
self.elapsed = int.from_bytes(self.readInf(info, "elapsed"), "little")
|
|
# Getting date in 100th of nanosecond need to convert NT time
|
|
# to UNIX time and offset localtime
|
|
self.lastsave = int(
|
|
(
|
|
struct.unpack("q", self.readInf(info, "date"))[0] / 10000
|
|
- 11644473600000
|
|
)
|
|
- (time.localtime().tm_gmtoff * 1000)
|
|
)
|
|
info.close()
|
|
|
|
def readInf(self, inf: BinaryIO, key: str):
|
|
inf.seek(self._saveInfLayout[key][0])
|
|
return inf.read(self._saveInfLayout[key][1] - self._saveInfLayout[key][0])
|
|
|
|
def allFiles(self) -> list[str]:
|
|
files = [str(file) for file in self._filepath.glob("./*")]
|
|
files.append(str(self._filepath))
|
|
return files
|
|
|
|
def getCreationTime(self) -> QDateTime:
|
|
return QDateTime.fromMSecsSinceEpoch(self.lastsave)
|
|
|
|
def getElapsed(self) -> str:
|
|
return str(datetime.timedelta(seconds=self.elapsed))
|
|
|
|
def getName(self) -> str:
|
|
return self.name
|
|
|
|
def getLand(self) -> str:
|
|
return str(self.land)
|
|
|
|
def getSaveGroupIdentifier(self):
|
|
return self._filepath.parent.parent.name
|
|
|
|
|
|
def getMetadata(savepath: Path, save: mobase.ISaveGame) -> Mapping[str, str]:
|
|
assert isinstance(save, BlackAndWhite2SaveGame)
|
|
return {
|
|
"Name": save.getName(),
|
|
"Profile": save.getSaveGroupIdentifier(),
|
|
"Land": save.getLand(),
|
|
"Saved at": format_date(save.getCreationTime()),
|
|
"Elapsed time": save.getElapsed(),
|
|
}
|
|
|
|
|
|
PSTART_MENU = (
|
|
str(os.getenv("ProgramData")) + "\\Microsoft\\Windows\\Start Menu\\Programs"
|
|
)
|
|
|
|
|
|
class BlackAndWhite2Game(BasicGame):
|
|
Name = "Black & White 2 Support Plugin"
|
|
Author = "Ilyu"
|
|
Version = "1.0.1"
|
|
|
|
GameName = "Black & White 2"
|
|
GameShortName = "BW2"
|
|
GameNexusName = "blackandwhite2"
|
|
GameDataPath = "%GAME_PATH%"
|
|
GameBinary = "white.exe"
|
|
GameDocumentsDirectory = "%DOCUMENTS%/Black & White 2"
|
|
GameSavesDirectory = "%GAME_DOCUMENTS%/Profiles"
|
|
GameSupportURL = (
|
|
r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/"
|
|
"Game:-Black-&-White-2"
|
|
)
|
|
|
|
_program_link = PSTART_MENU + "\\Black & White 2\\Black & White® 2.lnk"
|
|
|
|
def init(self, organizer: mobase.IOrganizer) -> bool:
|
|
BasicGame.init(self, organizer)
|
|
|
|
self._register_feature(BlackAndWhite2ModDataChecker())
|
|
self._register_feature(BasicLocalSavegames(self))
|
|
self._register_feature(
|
|
BasicGameSaveGameInfo(get_metadata=getMetadata, max_width=400)
|
|
)
|
|
return True
|
|
|
|
def detectGame(self):
|
|
super().detectGame()
|
|
|
|
program_path = Path(self._program_link)
|
|
if program_path.exists():
|
|
installation_path = Path(QFileInfo(self._program_link).symLinkTarget())
|
|
if installation_path.exists():
|
|
self.setGamePath(installation_path.parent)
|
|
|
|
return
|
|
|
|
def executables(self) -> list[mobase.ExecutableInfo]:
|
|
execs = super().executables()
|
|
|
|
"""
|
|
A bat file to load modded executables from VFS.
|
|
"""
|
|
workaroundPath = self._gamePath + "/" + self.GameBinary[:-4] + ".bat"
|
|
|
|
try:
|
|
workaround = open(workaroundPath, "rt")
|
|
except FileNotFoundError:
|
|
with open(workaroundPath, "wt") as workaround:
|
|
workaround.write('start "" "' + self.GameBinary + '"')
|
|
workaround.close()
|
|
|
|
execs.append(
|
|
mobase.ExecutableInfo(
|
|
self.GameShortName + " Modded Exec", QFileInfo(workaroundPath)
|
|
)
|
|
)
|
|
|
|
return execs
|
|
|
|
def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]:
|
|
profiles: list[Path] = []
|
|
for path in Path(folder.absolutePath()).glob("*/Saved Games/*"):
|
|
if (
|
|
path.name == "Autosave"
|
|
or path.name == "Pictures"
|
|
or "_invalid" in path.name
|
|
):
|
|
continue
|
|
if path.is_dir():
|
|
saveFolder = QDir(str(path))
|
|
if not saveFolder.exists("SaveGame.inf"):
|
|
savePath = saveFolder.absolutePath()
|
|
QFile.rename(savePath, savePath + "_invalid")
|
|
continue
|
|
else:
|
|
continue
|
|
|
|
profiles.append(path)
|
|
|
|
return [BlackAndWhite2SaveGame(path) for path in profiles]
|
|
|
|
|
|
class BOTGGame(BlackAndWhite2Game):
|
|
Name = "Black & White 2 Battle of the Gods Support Plugin"
|
|
|
|
GameName = "Black & White 2 Battle of the Gods"
|
|
GameShortName = "BOTG"
|
|
GameBinary = "BattleOfTheGods.exe"
|
|
GameDocumentsDirectory = "%DOCUMENTS%/Black & White 2 - Battle of the Gods"
|
|
GameSavesDirectory = "%GAME_DOCUMENTS%/Profiles"
|
|
|
|
_program_link = (
|
|
PSTART_MENU + "\\Black & White 2 Battle of the Gods"
|
|
"\\Black & White® 2 Battle of the Gods.lnk"
|
|
)
|