Repository: lutris/lutris

Issue:
Feature Request: Add Vita3K runner support
This request is to add support for the Vita3K emulator which is an emulator for the Sony Playstation Vita.

Information about the emulator is located at https://vita3k.org/

The emulator launches from the CLI using a Title ID instead of a file path, so the json runner cannot be used as it [raises an exception](https://github.com/lutris/lutris/blob/368f9128eabe220e7fc7757b5f555b054cb11d1c/lutris/runners/json.py#L56-L58) if the "main_file" option is not an actual file path.
Instead a runner needs to be written in python to avoid performing an existence check on the "main_file" game_options entry to successfully run the emulator.

An implementation of a Vita 3k runner is posted inline:

```python
import os
from gettext import gettext as _

from lutris.exceptions import MissingGameExecutableError
from lutris.runners.runner import Runner
from lutris.util import system
from lutris.util.log import logger

class MissingVitaTitleIDError(MissingGameExecutableError):
    """Raise when the Title ID field has not be supplied to the Vita runner game options"""

    def __init__(self, message=None, *args, **kwargs):
        if not message:
            message = _("The Vita App has no Title ID set")

        super().__init__(message, *args, **kwargs)


class vita3k(Runner):
    human_name = _("Vita3K")
    platforms = [_("Sony PlayStation Vita")]
    description = _("Sony PlayStation Vita emulator")
    runnable_alone = True
    runner_executable = "vita3k/Vita3K-x86_64.AppImage"
    flatpak_id = None
    download_url = "https://github.com/Vita3K/Vita3K/releases/download/continuous/Vita3K-x86_64.AppImage"
    game_options = [
        {
            "option": "main_file",
            "type": "string",
            "label": _("Title ID of Installed Application"),
            "argument": "-r",
            "help": _("Title ID of installed application. Eg.\"PCSG00042\". User installed apps are located in ux0:/app/&lt;title-id&gt;."),
        }
    ]
    runner_options = [
        {
            "option": "fullscreen",
            "type": "bool",
            "label": _("Fullscreen"),
            "default": True,
            "argument": "-F",
            "help": _("Start the emulator in fullscreen mode."),
        },
        {
            "option": "config",
            "type": "file",
            "label": _("Config location"),
            "argument": "-c",
            "help": _("Get a configuration file from a given location. If a filename is given, it must end with \".yml\", otherwise it will be assumed to be a directory."),
        },
        {
            "option": "load-config",
            "label": _("Load configuration file"),
            "type": "bool",
            "argument": "-f",
            "help": _("If trues, informs the emualtor to load the config file from the \"Config location\" option.")
        },
    ]

    # Vita3k uses an AppImage and doesn't require the Lutris runtime.
    system_options_override = [{"option": "disable_runtime", "default": True}]

    def play(self):
        """Run the game."""
        arguments = self.get_command()

        # adds arguments from the supplied option dictionary to the arguments list
        def append_args(option_dict, config):
            for option in option_dict:
                if option["option"] not in config:
                    continue
                if option["type"] == "bool":
                    if self.runner_config.get(option["option"]):
                        if 'argument' in option:
                            arguments.append(option["argument"])
                elif option["type"] == "choice":
                    if self.runner_config.get(option["option"]) != "off":
                        if 'argument' in option:
                            arguments.append(option["argument"])
                        arguments.append(config.get(option["option"]))
                elif option["type"] in ("string", "file"):
                    if 'argument' in option:
                        arguments.append(option["argument"])
                    arguments.append(config.get(option["option"]))
                else:
                    raise RuntimeError("Unhandled type %s" % option["type"])

        # Append the runner arguments first, and game arguments afterwards
        append_args(self.runner_options, self.runner_config)

        title_id = self.game_config.get("main_file") or ""
        if not title_id:
            raise MissingVitaTitleIDError(_("The Vita App has no Title ID set"))

        append_args(self.game_options, self.game_config)
        return {"command": arguments}

    @property
    def game_path(self):
        return self.game_config.get(self.entry_point_option, '')

```


Discussion:

Resolving patch:
diff --git a/lutris/runners/__init__.py b/lutris/runners/__init__.py
--- a/lutris/runners/__init__.py
+++ b/lutris/runners/__init__.py
@@ -33,6 +33,7 @@
     # Sony
     "pcsx2",
     "rpcs3",
+    "vita3k",
     # Sega
     "osmose",
     "reicast",
diff --git a/lutris/runners/vita3k.py b/lutris/runners/vita3k.py
new file mode 100644
--- /dev/null
+++ b/lutris/runners/vita3k.py
@@ -0,0 +1,101 @@
+import os
+from gettext import gettext as _
+
+from lutris.exceptions import MissingGameExecutableError
+from lutris.runners.runner import Runner
+from lutris.util import system
+from lutris.util.log import logger
+
+class MissingVitaTitleIDError(MissingGameExecutableError):
+    """Raise when the Title ID field has not be supplied to the Vita runner game options"""
+
+    def __init__(self, message=None, *args, **kwargs):
+        if not message:
+            message = _("The Vita App has no Title ID set")
+
+        super().__init__(message, *args, **kwargs)
+
+
+class vita3k(Runner):
+    human_name = _("Vita3K")
+    platforms = [_("Sony PlayStation Vita")]
+    description = _("Sony PlayStation Vita emulator")
+    runnable_alone = True
+    runner_executable = "vita3k/Vita3K-x86_64.AppImage"
+    flatpak_id = None
+    download_url = "https://github.com/Vita3K/Vita3K/releases/download/continuous/Vita3K-x86_64.AppImage"
+    game_options = [
+        {
+            "option": "main_file",
+            "type": "string",
+            "label": _("Title ID of Installed Application"),
+            "argument": "-r",
+            "help": _("Title ID of installed application. Eg.\"PCSG00042\". User installed apps are located in ux0:/app/&lt;title-id&gt;."),
+        }
+    ]
+    runner_options = [
+        {
+            "option": "fullscreen",
+            "type": "bool",
+            "label": _("Fullscreen"),
+            "default": True,
+            "argument": "-F",
+            "help": _("Start the emulator in fullscreen mode."),
+        },
+        {
+            "option": "config",
+            "type": "file",
+            "label": _("Config location"),
+            "argument": "-c",
+            "help": _("Get a configuration file from a given location. If a filename is given, it must end with \".yml\", otherwise it will be assumed to be a directory."),
+        },
+        {
+            "option": "load-config",
+            "label": _("Load configuration file"),
+            "type": "bool",
+            "argument": "-f",
+            "help": _("If trues, informs the emualtor to load the config file from the \"Config location\" option.")
+        },
+    ]
+
+    # Vita3k uses an AppImage and doesn't require the Lutris runtime.
+    system_options_override = [{"option": "disable_runtime", "default": True}]
+
+    def play(self):
+        """Run the game."""
+        arguments = self.get_command()
+
+        # adds arguments from the supplied option dictionary to the arguments list
+        def append_args(option_dict, config):
+            for option in option_dict:
+                if option["option"] not in config:
+                    continue
+                if option["type"] == "bool":
+                    if self.runner_config.get(option["option"]):
+                        if 'argument' in option:
+                            arguments.append(option["argument"])
+                elif option["type"] == "choice":
+                    if self.runner_config.get(option["option"]) != "off":
+                        if 'argument' in option:
+                            arguments.append(option["argument"])
+                        arguments.append(config.get(option["option"]))
+                elif option["type"] in ("string", "file"):
+                    if 'argument' in option:
+                        arguments.append(option["argument"])
+                    arguments.append(config.get(option["option"]))
+                else:
+                    raise RuntimeError("Unhandled type %s" % option["type"])
+
+        # Append the runner arguments first, and game arguments afterwards
+        append_args(self.runner_options, self.runner_config)
+
+        title_id = self.game_config.get("main_file") or ""
+        if not title_id:
+            raise MissingVitaTitleIDError(_("The Vita App has no Title ID set"))
+
+        append_args(self.game_options, self.game_config)
+        return {"command": arguments}
+
+    @property
+    def game_path(self):
+        return self.game_config.get(self.entry_point_option, '')


Test patch:
diff --git a/tests/runners/test_vita3k.py b/tests/runners/test_vita3k.py
new file mode 100644
--- /dev/null
+++ b/tests/runners/test_vita3k.py
@@ -0,0 +1,51 @@
+import unittest
+from unittest.mock import MagicMock, patch
+
+from lutris.runners.vita3k import MissingVitaTitleIDError
+from lutris.runners.vita3k import vita3k
+
+
+class TestVita3kRunner(unittest.TestCase):
+    def setUp(self):
+        self.runner = vita3k()
+        self.runner.get_executable = lambda: "vita3k"
+        self.runner.get_command = lambda: ["vita3k"]
+
+    @patch("os.path.isfile")
+    def test_empty_title_id(self, mock_isfile):
+        main_file = ""
+        mock_isfile.return_value = True
+        mock_config = MagicMock()
+        mock_config.game_config = {"main_file": main_file}
+        mock_config.runner_config = MagicMock()
+        self.runner.config = mock_config
+        with self.assertRaises(MissingVitaTitleIDError) as cm:
+            self.runner.play()
+
+    @patch("lutris.util.system.path_exists")
+    @patch("os.path.isfile")
+    def test_play_fullscreen(self, mock_path_exists, mock_isfile):
+        main_file = "PCSG00042"
+        mock_path_exists.return_value = True
+        mock_isfile.return_value = True
+        mock_config = MagicMock()
+        mock_config.game_config = {"main_file": main_file}
+        mock_config.runner_config = {"fullscreen": True}
+        self.runner.config = mock_config
+        expected = {"command": [self.runner.get_executable(), "-F", "-r", main_file]}
+        self.assertEqual(self.runner.play(), expected)
+
+    @patch("lutris.util.system.path_exists")
+    @patch("os.path.isfile")
+    def test_play(self, mock_path_exists, mock_isfile):
+        main_file = "/valid/path/to/iso"
+        mock_path_exists.return_value = True
+        mock_isfile.return_value = True
+        mock_config = MagicMock()
+        mock_config.game_config = {"main_file": main_file}
+        mock_config.runner_config = {
+            "fullscreen": False
+        }
+        self.runner.config = mock_config
+        expected = {"command": [self.runner.get_executable(), "-r", main_file]}
+        self.assertEqual(self.runner.play(), expected)