import datetime import os import struct import time from collections.abc import Mapping from pathlib import Path from typing import BinaryIO import mobase from PyQt6.QtCore import QDateTime, QDir, QFile, QFileInfo 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 = { "": ["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 = { "": ["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 = "" 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._featureMap[mobase.ModDataChecker] = BlackAndWhite2ModDataChecker() self._featureMap[mobase.LocalSavegames] = BasicLocalSavegames( self.savesDirectory() ) self._featureMap[mobase.SaveGameInfo] = 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" )