From 9618fb7fff60cc8a5ebb78efba2e0b89986efdab Mon Sep 17 00:00:00 2001 From: Rilic Date: Sun, 23 Jul 2023 20:24:09 +0100 Subject: [PATCH] Implement initial framework for Dolphin importer - Uses cache reading code from Lutris by strycore. https://github.com/lutris/lutris/blob/master/lutris/util/dolphin/cache_reader.py#L23 --- data/gtk/preferences.blp | 14 +++ data/hu.kramo.Cartridges.gschema.xml.in | 6 + flatpak/hu.kramo.Cartridges.Devel.json | 1 + src/importer/sources/dolphin_source.py | 82 ++++++++++++++ src/main.py | 4 + src/preferences.py | 6 + src/utils/dolphin_cache_reader.py | 145 ++++++++++++++++++++++++ 7 files changed, 258 insertions(+) create mode 100644 src/importer/sources/dolphin_source.py create mode 100644 src/utils/dolphin_cache_reader.py diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp index fbc2dee..a0e8305 100644 --- a/data/gtk/preferences.blp +++ b/data/gtk/preferences.blp @@ -198,6 +198,20 @@ template $PreferencesWindow : Adw.PreferencesWindow { } } + Adw.ExpanderRow dolphin_expander_row { + title: _("Dolphin"); + show-enable-switch: true; + + Adw.ActionRow dolphin_cache_action_row { + title: _("Cache Location"); + + Button dolphin_cache_file_chooser_button { + icon-name: "folder-symbolic"; + valign: center; + } + } + } + Adw.ExpanderRow itch_expander_row { title: _("itch"); show-enable-switch: true; diff --git a/data/hu.kramo.Cartridges.gschema.xml.in b/data/hu.kramo.Cartridges.gschema.xml.in index edd5aec..bf1199d 100644 --- a/data/hu.kramo.Cartridges.gschema.xml.in +++ b/data/hu.kramo.Cartridges.gschema.xml.in @@ -52,6 +52,12 @@ "~/.var/app/com.usebottles.bottles/data/bottles/" + + true + + + "~/.var/app/org.DolphinEmu.dolphin-emu/cache/dolphin-emu/" + true diff --git a/flatpak/hu.kramo.Cartridges.Devel.json b/flatpak/hu.kramo.Cartridges.Devel.json index 1c2ecea..e929dfa 100644 --- a/flatpak/hu.kramo.Cartridges.Devel.json +++ b/flatpak/hu.kramo.Cartridges.Devel.json @@ -12,6 +12,7 @@ "--socket=wayland", "--talk-name=org.freedesktop.Flatpak", "--filesystem=host:ro", + "--filesystem=~/.var/app/org.DolphinEmu.dolphin-emu:ro", "--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro", "--filesystem=~/.var/app/net.lutris.Lutris/:ro", "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro", diff --git a/src/importer/sources/dolphin_source.py b/src/importer/sources/dolphin_source.py new file mode 100644 index 0000000..6398295 --- /dev/null +++ b/src/importer/sources/dolphin_source.py @@ -0,0 +1,82 @@ +# dolphin_source.py +# +# Copyright 2023 Rilic +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from time import time + +from src import shared +from src.game import Game +from src.importer.sources.location import Location +from src.importer.sources.source import Source, SourceIterationResult, SourceIterator +from src.utils.dolphin_cache_reader import DolphinCacheReader + + +class DolphinIterator(SourceIterator): + source: "DolphinSource" + + def generator_builder(self) -> SourceIterationResult: + added_time = int(time()) + + cache_reader = DolphinCacheReader(self.source.cache_location["cache_file"]) + games_data = cache_reader.get_games() + + for game_data in games_data: + print(game_data["file_name"]) + + # Build game + game = Game(values) + values = { + "source": self.source.id, + "added": added_time, + # "name": item["label"], + # "game_id": self.source.game_id_format.format(game_id=game_id), + # "executable": self.source.executable_format.format( + # rom_path=item["path"], + # core_path=core_path, + # ), + } + + game = Game(values) + additional_data = {} + + yield (game, additional_data) + + +class DolphinSource(Source): + name = "Dolphin" + available_on = {"linux"} + iterator_class = DolphinIterator + + cache_location = Location( + schema_key="dolphin-cache-location", + candidates=( + shared.flatpak_dir / "org.DolphinEmu.dolphin-emu" / "cache" / "dolphin-emu", + shared.home / ".cache" / "dolphin-emu", + ), + paths={ + "cache_file": (False, "gamelist.cache"), + }, + ) + + @property + def executable_format(self): + self.config_location.resolve() + is_flatpak = self.data_location.root.is_relative_to(shared.flatpak_dir) + base = "flatpak run org.DolphinEmu.dolphin-emu" if is_flatpak else "dolphin-emu" + args = '-e "{rom_path}"' + return f"{base} {args}" diff --git a/src/main.py b/src/main.py index 949029d..c52de20 100644 --- a/src/main.py +++ b/src/main.py @@ -35,6 +35,7 @@ from src.details_window import DetailsWindow from src.game import Game from src.importer.importer import Importer from src.importer.sources.bottles_source import BottlesSource +from src.importer.sources.dolphin_source import DolphinSource from src.importer.sources.flatpak_source import FlatpakSource from src.importer.sources.heroic_source import HeroicSource from src.importer.sources.itch_source import ItchSource @@ -230,6 +231,9 @@ class CartridgesApplication(Adw.Application): if shared.schema.get_boolean("bottles"): importer.add_source(BottlesSource()) + if shared.schema.get_boolean("dolphin"): + importer.add_source(DolphinSource()) + if shared.schema.get_boolean("flatpak"): importer.add_source(FlatpakSource()) diff --git a/src/preferences.py b/src/preferences.py index 9e9be4d..fceb7d3 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -26,6 +26,7 @@ from gi.repository import Adw, Gio, GLib, Gtk from src import shared from src.importer.sources.bottles_source import BottlesSource +from src.importer.sources.dolphin_source import DolphinSource from src.importer.sources.flatpak_source import FlatpakSource from src.importer.sources.heroic_source import HeroicSource from src.importer.sources.itch_source import ItchSource @@ -74,6 +75,10 @@ class PreferencesWindow(Adw.PreferencesWindow): bottles_data_action_row = Gtk.Template.Child() bottles_data_file_chooser_button = Gtk.Template.Child() + dolphin_expander_row = Gtk.Template.Child() + dolphin_cache_action_row = Gtk.Template.Child() + dolphin_cache_file_chooser_button = Gtk.Template.Child() + itch_expander_row = Gtk.Template.Child() itch_config_action_row = Gtk.Template.Child() itch_config_file_chooser_button = Gtk.Template.Child() @@ -133,6 +138,7 @@ class PreferencesWindow(Adw.PreferencesWindow): # Sources settings for source_class in ( BottlesSource, + DolphinSource, FlatpakSource, HeroicSource, ItchSource, diff --git a/src/utils/dolphin_cache_reader.py b/src/utils/dolphin_cache_reader.py new file mode 100644 index 0000000..8c997ea --- /dev/null +++ b/src/utils/dolphin_cache_reader.py @@ -0,0 +1,145 @@ +"""Reads the Dolphin game database, stored in a binary format""" +# Copyright 2022 strycore - Lutris + +import logging +from pathlib import Path + +SUPPORTED_CACHE_VERSION = 20 + + +def get_hex_string(string): + """Return the hexadecimal representation of a string""" + return " ".join("{:02x}".format(c) for c in string) + + +def get_word_len(string): + """Return the length of a string as specified in the Dolphin format""" + return int("0x" + "".join("{:02x}".format(c) for c in string[::-1]), 0) + + +# https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.h#L140 +# https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.cpp#L318 + + +class DolphinCacheReader: + header_size = 20 + structure = { + "valid": "b", + "file_path": "s", + "file_name": "s", + "file_size": 8, + "volume_size": 8, + "volume_size_is_accurate": 1, + "is_datel_disc": 1, + "is_nkit": 1, + "short_names": "a", + "long_names": "a", + "short_makers": "a", + "long_makers": "a", + "descriptions": "a", + "internal_name": "s", + "game_id": "s", + "gametdb_id": "s", + "title_id": 8, + "maker_id": "s", + "region": 4, + "country": 4, + "platform": 1, + "platform_": 3, + "blob_type": 4, + "block_size": 8, + "compression_method": "s", + "revision": 2, + "disc_number": 1, + "apploader_date": "s", + "custom_name": "s", + "custom_description": "s", + "custom_maker": "s", + "volume_banner": "i", + "custom_banner": "i", + "default_cover": "c", + "custom_cover": "c", + } + + def __init__(self, cache_file: Path): + self.offset = 0 + with open(cache_file, "rb") as dolphin_cache_file: + self.cache_content = dolphin_cache_file.read() + cache_version = get_word_len(self.cache_content[:4]) + if cache_version != SUPPORTED_CACHE_VERSION: + logging.warning( + "Dolphin cache version expected %s but found %s", + SUPPORTED_CACHE_VERSION, + cache_version, + ) + + def get_game(self): + game = {} + for key, i in self.structure.items(): + if i == "s": + game[key] = self.get_string() + elif i == "b": + game[key] = self.get_boolean() + elif i == "a": + game[key] = self.get_array() + elif i == "i": + game[key] = self.get_image() + elif i == "c": + game[key] = self.get_cover() + else: + game[key] = self.get_raw(i) + return game + + def get_games(self): + self.offset += self.header_size + games = [] + while self.offset < len(self.cache_content): + try: + games.append(self.get_game()) + except Exception as ex: + logging.error("Failed to read Dolphin database: %s", ex) + return games + + def get_boolean(self): + res = bool(get_word_len(self.cache_content[self.offset : self.offset + 1])) + self.offset += 1 + return res + + def get_array(self): + array_len = get_word_len(self.cache_content[self.offset : self.offset + 4]) + self.offset += 4 + array = {} + for _i in range(array_len): + array_key = self.get_raw(4) + array[array_key] = self.get_string() + return array + + def get_image(self): + data_len = get_word_len(self.cache_content[self.offset : self.offset + 4]) + self.offset += 4 + res = self.cache_content[ + self.offset : self.offset + data_len * 4 + ] # vector + self.offset += data_len * 4 + width = get_word_len(self.cache_content[self.offset : self.offset + 4]) + self.offset += 4 + height = get_word_len(self.cache_content[self.offset : self.offset + 4]) + self.offset += 4 + return (width, height), res + + def get_cover(self): + array_len = get_word_len(self.cache_content[self.offset : self.offset + 4]) + self.offset += 4 + return self.get_raw(array_len) + + def get_raw(self, word_len): + res = get_hex_string(self.cache_content[self.offset : self.offset + word_len]) + self.offset += word_len + return res + + def get_string(self): + word_len = get_word_len(self.cache_content[self.offset : self.offset + 4]) + self.offset += 4 + string = self.cache_content[self.offset : self.offset + word_len] + self.offset += word_len + return string.decode("utf8")