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")