"""Battle.net service""" import json import os from gettext import gettext as _ from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.services.base import BaseService from lutris.services.lutris import sync_media from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.battlenet.definitions import ProductDbInfo from lutris.util.log import logger # Use pure-Python protobuf implementation for compatibility across all protobuf versions. # The old-style generated pb2 file is incompatible with protobuf 4.x+ native implementation. os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") try: from lutris.util.battlenet.product_db_pb2 import ProductDb BNET_ENABLED = True except Exception as ex: # We do get strange Google-defined exceptions from problems with protobuf, so # let's just catch (almost) everything. We do not want Lutris to crash, rather # we just want to suppress Battle.net and nothing else. logger.warning("The Battle.net source is unavailable because Google protobuf could not be loaded: %s", ex) BNET_ENABLED = False GAME_IDS = { "s1": ("s1", "StarCraft", "S1", "starcraft-remastered"), "s2": ("s2", "StarCraft II", "S2", "starcraft-ii"), "wow": ("wow", "World of Warcraft", "WoW", "world-of-warcraft"), "wow_classic": ("wow_classic", "World of Warcraft Classic", "WoW_wow_classic", "world-of-warcraft-classic"), "wow_classic_era": ( "wow_classic_era", "World of Warcraft Classic Era", "WoW_wow_classic_era", "world-of-warcraft-classic", ), "pro": ("pro", "Overwatch 2", "Pro", "overwatch-2"), "w1": ("w1", "Warcraft: Orcs & Humans", "W1", "warcraft-orcs-humans"), "w1r": ("w1r", "Warcraft I: Remastered", "W1R", "warcraft-i-remastered"), "w2bn": ("w2bn", "Warcraft II: Battle.net Edition", "W2BN", "warcraft-ii-battle-net-edition"), "w2r": ("w2r", "Warcraft II: Remastered", "W2R", "warcraft-ii-remastered"), "w3": ("w3", "Warcraft III", "W3", "warcraft-iii-reforged"), "gryphon": ("gryphon", "Warcraft Rumble", "GRY", "warcraft-rumble"), "hsb": ("hsb", "Hearthstone", "WTCG", "hearthstone"), "hero": ("hero", "Heroes of the Storm", "Hero", "heroes-of-the-storm"), "d3cn": ("d3cn", "暗黑破壞神III", "D3CN", "diablo-iii"), "d3": ("d3", "Diablo III", "D3", "diablo-iii"), "fenris": ("fenris", "Diablo IV", "Fen", "diablo-iv"), "viper": ("viper", "Call of Duty: Black Ops 4", "VIPR", "call-of-duty-black-ops-4"), "odin": ("odin", "Call of Duty: Modern Warfare", "ODIN", "call-of-duty-modern-warfare"), "lazarus": ( "lazarus", "Call of Duty: MW2 Campaign Remastered", "LAZR", "call-of-duty-modern-warfare-2-campaign-remastered", ), "zeus": ("zeus", "Call of Duty: Black Ops Cold War", "ZEUS", "call-of-duty-black-ops-cold-war"), "auks": ("auks", "Call of Duty: Modern Warfare II", "AUKS", "call-of-duty-modern-warfare-ii"), "codhq": ("codhq", "Call of Duty HQ", "CODHQ", "call-of-duty-hq"), "rtro": ("rtro", "Blizzard Arcade Collection", "RTRO", "blizzard-arcade-collection"), "wlby": ("wlby", "Crash Bandicoot 4: It's About Time", "WLBY", "crash-bandicoot-4-its-about-time"), "osi": ("osi", "Diablo® II: Resurrected", "OSI", "diablo-2-ressurected"), "fore": ("fore", "Call of Duty: Vanguard", "FORE", "call-of-duty-vanguard"), "d2": ("d2", "Diablo® II", "Diablo II", "diablo-ii"), "d2LOD": ("d2LOD", "Diablo® II: Lord of Destruction®", "Diablo II", "diablo-ii-lord-of-destruction"), "w3ROC": ("w3ROC", "Warcraft® III: Reign of Chaos", "Warcraft III", "warcraft-iii-reign-of-chaos"), "w3tft": ("w3tft", "Warcraft® III: The Frozen Throne®", "Warcraft III", "warcraft-iii-the-frozen-throne"), "sca": ("sca", "StarCraft® Anthology", "Starcraft", "starcraft"), "anbs": ("anbs", "Diablo Immortal", "ANBS", "diablo-immortal"), } class BattleNetCover(ServiceMedia): service = "battlenet" size = (176, 234) file_patterns = ["%s.jpg"] dest_path = os.path.join(settings.CACHE_DIR, "battlenet/coverart") api_field = "coverart" class BattleNetGame(ServiceGame): """Game from Battle.net""" service = "battlenet" runner = "wine" installer_slug = "battlenet" @classmethod def create(cls, blizzard_game): """Create a service game from an entry from the Dolphin cache""" service_game = cls() service_game.appid = blizzard_game[0] service_game.name = blizzard_game[1] service_game.slug = blizzard_game[3] service_game.details = json.dumps( { "id": blizzard_game[0], "name": blizzard_game[1], "product_code": blizzard_game[2], "slug": blizzard_game[3], "coverart": "https://lutris.net/games/cover/%s.jpg" % blizzard_game[3], } ) return service_game class BattleNetService(BaseService): """Service class for Battle.net""" id = "battlenet" name = _("Battle.net") icon = "battlenet" runner = "wine" medias = {"coverart": BattleNetCover} default_format = "coverart" client_installer = "battlenet" cookies_path = os.path.join(settings.CACHE_DIR, ".bnet.auth") cache_path = os.path.join(settings.CACHE_DIR, "bnet-library.json") redirect_uris = ["https://lutris.net"] @property def battlenet_config_path(self): return "" def load(self): games = [BattleNetGame.create(game) for game in GAME_IDS.values()] for game in games: game.save() return games def add_installed_games(self): """Scan an existing Battle.net install for games""" bnet_game = get_game_by_field(self.client_installer, "slug") if not bnet_game: raise RuntimeError("Battle.net is not installed in Lutris") bnet_prefix = bnet_game["directory"].split("drive_c")[0] parser = BlizzardProductDbParser(bnet_prefix) installed_slugs = [] for game in parser.games: slug = self.install_from_battlenet(bnet_game, game) if slug: installed_slugs.append(slug) sync_media(installed_slugs) def install_from_battlenet(self, bnet_game, game): app_id = game.ngdp logger.debug("Installing Battle.net game %s", app_id) service_game = ServiceGameCollection.get_game("battlenet", app_id) if not service_game: logger.error("Aborting install, %s is not present in the game library.", app_id) return lutris_game_id = service_game["slug"] + "-" + self.id existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig(game_config_id=bnet_game["configpath"]).game_level game_config["game"]["args"] = '--exec="launch %s"' % game.ngdp configpath = write_game_config(lutris_game_id, game_config) slug = service_game["slug"] add_game( name=service_game["name"], runner=bnet_game["runner"], slug=slug, directory=bnet_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=app_id, platform="Windows", ) return slug def get_installed_slug(self, db_game): return db_game["slug"] def generate_installer(self, db_game, bnet_db_game): bnet_app = Game(bnet_db_game["id"]) bnet_exe = bnet_app.config.game_config["exe"] if not os.path.isabs(bnet_exe): bnet_exe = os.path.join(bnet_app.config.game_config["prefix"], bnet_exe) return { "name": db_game["name"], "version": self.name, "slug": db_game["slug"] + "-" + self.id, "game_slug": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "args": '--exec="launch %s"' % db_game["appid"], }, "installer": [ { "task": { "name": "wineexec", "executable": bnet_exe, "args": '--exec="install %s"' % db_game["appid"], "prefix": bnet_app.config.game_config["prefix"], "description": ( "Battle.net will now open. Please launch " "the installation of %s then close Battle.net " "once the game has been downloaded." % db_game["name"] ), } } ], }, } def get_installed_runner_name(self, db_game): return self.runner def install(self, db_game): bnet_game = get_game_by_field(self.client_installer, "slug") application = Gio.Application.get_default() application.show_installer_window( [self.generate_installer(db_game, bnet_game)], service=self, appid=db_game["appid"] ) class BlizzardProductDbParser: # Adapted from DatabaseParser in https://github.com/bartok765/galaxy_blizzard_plugin NOT_GAMES = ("bna", "agent") PRODUCT_DB_PATH = "/drive_c/ProgramData/Battle.net/Agent/product.db" def __init__(self, prefix_path): self.data = self.load_product_db(prefix_path + self.PRODUCT_DB_PATH) self.products = {} self._region = "" self.parse() @property def region(self): return self._region @staticmethod def load_product_db(product_db_path): with open(product_db_path, "rb") as f: pdb = f.read() return pdb @property def games(self): if self.products: return [v for k, v in self.products.items() if k not in self.NOT_GAMES] return [] def parse(self): self.products = {} database = ProductDb() database.ParseFromString(self.data) for product_install in database.product_installs: # pylint: disable=no-member # process region if product_install.product_code in ["agent", "bna"] and not self.region: self._region = product_install.settings.play_region ngdp_code = product_install.product_code uninstall_tag = product_install.uid install_path = product_install.settings.install_path playable = product_install.cached_product_state.base_product_state.playable version = product_install.cached_product_state.base_product_state.current_version_str installed = product_install.cached_product_state.base_product_state.installed self.products[ngdp_code] = ProductDbInfo( uninstall_tag, ngdp_code, install_path, version, playable, installed )