diff --git a/Config.in b/Config.in
index e2c232cccc9..a5e8a2fa7ea 100644
--- a/Config.in
+++ b/Config.in
@@ -741,3 +741,17 @@ menu "Database"
endmenu
endmenu
+
+# Include hidden batocera-launch packages
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-cdogs/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-cgenius/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-drastic/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-fallout/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-flycast/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-kodi/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-openjazz/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-openjk/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-openjkdf2/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-openmohaa/Config.in"
+source "$BR2_EXTERNAL_BATOCERA_PATH/python-src/batocera-launch-rpcs3/Config.in"
diff --git a/Makefile b/Makefile
index 692ad9412e2..ddcb217d95c 100644
--- a/Makefile
+++ b/Makefile
@@ -95,7 +95,7 @@ endif
endif
# List of packages that are always good to rebuild for versioning/stamps etc
-MANDATORY_REBUILD_PKGS := batocera-es-system batocera-configgen batocera-system batocera-splash
+MANDATORY_REBUILD_PKGS := batocera-es-system batocera-launch batocera-configgen batocera-system batocera-splash
# All supported targets based on the board files in configs/, sorted for consistency
TARGETS := $(sort $(patsubst batocera-%.board,%,$(notdir $(wildcard $(PROJECT_DIR)/configs/*.board))))
@@ -454,7 +454,7 @@ uart:
# produce the final report files.
ES_SYSTEM_DIR = $(PROJECT_DIR)/package/batocera/emulationstation/batocera-es-system
-CONFIGGEN_DIR = $(PROJECT_DIR)/package/batocera/core/batocera-configgen
+LAUNCH_DIR = $(PROJECT_DIR)/python-src/batocera-launch
SYSTEMS_REPORT_VENV_SCRIPT = $(PROJECT_DIR)/.venv/bin/batocera-systems-report
SYSTEMS_REPORT_LAST_TARGET = $(lastword $(SYSTEMS_REPORT_TARGETS))
@@ -503,7 +503,7 @@ systems-report: _check_batocera_systems_report systems-report-data $(SYSTEMS_REP
$(SYSTEMS_REPORT_DATADIR) \
$(ES_SYSTEM_DIR)/es_systems.yml \
$(ES_SYSTEM_DIR)/systems-explanations.yml \
- $(CONFIGGEN_DIR)/configs \
+ $(LAUNCH_DIR)/resources/defaults \
$(SYSTEMS_REPORT_DIR)/batocera_systemsReport.json
cp -f $(SYSTEMS_REPORT_HTML) $(SYSTEMS_REPORT_DIR)/batocera_systemsReport.html
diff --git a/package/batocera/core/batocera-configgen/Config.in b/package/batocera/core/batocera-configgen/Config.in
index 2cfdf33896a..4e597ac0eea 100644
--- a/package/batocera/core/batocera-configgen/Config.in
+++ b/package/batocera/core/batocera-configgen/Config.in
@@ -2,6 +2,7 @@ config BR2_PACKAGE_BATOCERA_CONFIGGEN
bool "batocera configgen"
select BR2_PACKAGE_PYTHON3
select BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ select BR2_PACKAGE_BATOCERA_LAUNCH
select BR2_PACKAGE_PYTHON_PYYAML
select BR2_PACKAGE_PYTHON_RUAMEL_YAML
select BR2_PACKAGE_PYTHON_TOML
diff --git a/package/batocera/core/batocera-configgen/batocera-configgen.mk b/package/batocera/core/batocera-configgen/batocera-configgen.mk
index a8b6baa8360..eea654b39f4 100644
--- a/package/batocera/core/batocera-configgen/batocera-configgen.mk
+++ b/package/batocera/core/batocera-configgen/batocera-configgen.mk
@@ -9,6 +9,7 @@ BATOCERA_CONFIGGEN_SOURCE=
BATOCERA_CONFIGGEN_SETUP_TYPE = hatch
BATOCERA_CONFIGGEN_DEPENDENCIES = \
python-batocera-common \
+ batocera-launch \
python-toml \
python-evdev \
python-pyudev \
@@ -19,63 +20,18 @@ BATOCERA_CONFIGGEN_DEPENDENCIES = \
python-qrcode \
pysdl2 \
batocera-bezel-overlay
-BATOCERA_CONFIGGEN_INSTALL_STAGING = YES
BATOCERA_CONFIGGEN_OVERRIDE_SRCDIR=$(BR2_EXTERNAL_BATOCERA_PATH)/package/batocera/core/batocera-configgen/configgen
BATOCERA_CONFIGGEN_OVERRIDE_SRCDIR_RSYNC_EXCLUSIONS=--exclude=".*" --exclude="**/__pycache__/" --exclude="dist"
-ifeq ($(BR2_PACKAGE_BATOCERA_TARGET_X86_64_ANY),y)
-BATOCERA_CONFIGGEN_SYSTEM = x86_64
-else
-BATOCERA_CONFIGGEN_SYSTEM = $(BATOCERA_ARCH)
-endif
-
-define BATOCERA_CONFIGGEN_INSTALL_STAGING_CMDS
- mkdir -p $(STAGING_DIR)/usr/share/batocera/configgen
- cp $(BATOCERA_CONFIGGEN_PKGDIR)/configs/configgen-defaults.yml \
- $(STAGING_DIR)/usr/share/batocera/configgen/configgen-defaults.yml
- cp $(BATOCERA_CONFIGGEN_PKGDIR)/configs/configgen-defaults-$(BATOCERA_CONFIGGEN_SYSTEM).yml \
- $(STAGING_DIR)/usr/share/batocera/configgen/configgen-defaults-arch.yml
-endef
-
define BATOCERA_CONFIGGEN_CONFIGS
mkdir -p $(TARGET_DIR)/usr/share/batocera/configgen
+
cp -pr $(BATOCERA_CONFIGGEN_PKGDIR)/data \
$(TARGET_DIR)/usr/share/batocera/configgen/
- cp $(BATOCERA_CONFIGGEN_PKGDIR)/configs/configgen-defaults.yml \
- $(TARGET_DIR)/usr/share/batocera/configgen/configgen-defaults.yml
- cp $(BATOCERA_CONFIGGEN_PKGDIR)/configs/configgen-defaults-$(BATOCERA_CONFIGGEN_SYSTEM).yml \
- $(TARGET_DIR)/usr/share/batocera/configgen/configgen-defaults-arch.yml
cp $(BATOCERA_CONFIGGEN_PKGDIR)/scripts/call_achievements_hooks.sh \
$(TARGET_DIR)/usr/share/batocera/configgen/
- # evmapy default hotkeys file
- mkdir -p $(TARGET_DIR)/usr/share/evmapy
- cp $(BATOCERA_CONFIGGEN_PKGDIR)/hotkeys.keys $(TARGET_DIR)/usr/share/evmapy/hotkeys.keys
-endef
-
-define BATOCERA_CONFIGGEN_ES_HOOKS
- install -D -m 0755 $(BATOCERA_CONFIGGEN_PKGDIR)/scripts/powermode_launch_hooks.sh \
- $(TARGET_DIR)/usr/share/batocera/configgen/scripts/powermode_launch_hooks.sh
-endef
-
-define BATOCERA_CONFIGGEN_X86_HOOKS
- install -D -m 0755 $(BATOCERA_CONFIGGEN_PKGDIR)/scripts/tdp_hooks.sh \
- $(TARGET_DIR)/usr/share/batocera/configgen/scripts/tdp_hooks.sh
-
- install -D -m 0755 $(BATOCERA_CONFIGGEN_PKGDIR)/scripts/nvidia-workaround.sh \
- $(TARGET_DIR)/usr/share/batocera/configgen/scripts/nvidia-workaround.sh
-endef
-
-define BATOCERA_CONFIGGEN_SCRIPTS
- install -D -m 0755 $(BATOCERA_CONFIGGEN_PKGDIR)/scripts/batocera-joysticks-hotkeys.py \
- $(TARGET_DIR)/usr/bin/batocera-joysticks-hotkeys
endef
BATOCERA_CONFIGGEN_POST_INSTALL_TARGET_HOOKS = BATOCERA_CONFIGGEN_CONFIGS
-BATOCERA_CONFIGGEN_POST_INSTALL_TARGET_HOOKS += BATOCERA_CONFIGGEN_ES_HOOKS
-BATOCERA_CONFIGGEN_POST_INSTALL_TARGET_HOOKS += BATOCERA_CONFIGGEN_SCRIPTS
-
-ifeq ($(BR2_PACKAGE_BATOCERA_TARGET_X86_64_ANY),y)
- BATOCERA_CONFIGGEN_POST_INSTALL_TARGET_HOOKS += BATOCERA_CONFIGGEN_X86_HOOKS
-endif
$(eval $(python-package))
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/Emulator.py b/package/batocera/core/batocera-configgen/configgen/configgen/Emulator.py
index 012db4f0547..f5bc0084173 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/Emulator.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/Emulator.py
@@ -2,92 +2,31 @@
import logging
import xml.etree.ElementTree as ET
-from collections.abc import Mapping
from dataclasses import InitVar, dataclass, field
from typing import TYPE_CHECKING, Any
-import yaml
+from batocera_launch.config.defaults import load_defaults, load_system_defaults
-from .batoceraPaths import BATOCERA_CONF, BATOCERA_SHADERS, DEFAULTS_DIR, ES_SETTINGS, USER_SHADERS
+from .batoceraPaths import BATOCERA_CONF, BATOCERA_SHADERS, ES_SETTINGS, USER_SHADERS
from .config import Config, SystemConfig
from .exceptions import MissingEmulator
from .settings.unixSettings import UnixSettings
if TYPE_CHECKING:
- from argparse import Namespace
+ from collections.abc import Mapping
from pathlib import Path
from warnings import deprecated
+ from batocera_launch.cli.arguments import Arguments
+
from .gun import Guns
_logger = logging.getLogger(__name__)
-# adapted from https://gist.github.com/angstwad/bf22d1822c38a92ec0a9
-def _dict_merge(destination: dict[str, Any], source: Mapping[str, Any]) -> None:
- """Recursive dict merge. Inspired by :meth:``dict.update()``, instead of
- updating only top-level keys, dict_merge recurses down into dicts nested
- to an arbitrary depth, updating keys. The ``merge_dct`` is merged into
- ``dct``.
- :param destination: dict onto which the merge is executed
- :param source: dict merged into destination
- :return: None
- """
- for key, value in source.items():
- if key in destination and isinstance(destination[key], dict) and isinstance(value, Mapping):
- _dict_merge(destination[key], value)
- else:
- destination[key] = value
-
-
-def _load_defaults(system_name: str, default_yml: Path, default_arch_yml: Path, /) -> dict[str, Any] | None:
- try:
- defaults = yaml.load(default_yml.read_text(), Loader=yaml.CLoader)
- except Exception:
- return None
-
- arch_defaults: dict[str, Any] = {}
- if default_arch_yml.exists():
- loaded_arch_defaults = yaml.load(default_arch_yml.read_text(), Loader=yaml.CLoader)
- if loaded_arch_defaults is not None:
- arch_defaults = loaded_arch_defaults
-
- config: dict[str, Any] = {}
-
- if 'default' in defaults:
- config = defaults['default']
-
- if 'default' in arch_defaults:
- _dict_merge(config, arch_defaults['default'])
-
- if system_name in defaults:
- _dict_merge(config, defaults[system_name])
-
- if system_name in arch_defaults:
- _dict_merge(config, arch_defaults[system_name])
-
- return config
-
-
-def _load_system_config(system_name: str, /) -> dict[str, Any]:
- defaults = _load_defaults(
- system_name,
- DEFAULTS_DIR / 'configgen-defaults.yml',
- DEFAULTS_DIR / 'configgen-defaults-arch.yml'
- ) or {'emulator': {}, 'core': {}}
-
- # In the yaml files, the "options" structure is not flat, so we have to flatten it here
- # because the options are flat in batocera.conf to make it easier for end users to edit
- data: dict[str, Any] = {'emulator': defaults['emulator'], 'core': defaults['core']}
-
- if 'options' in defaults:
- _dict_merge(data, defaults['options'])
-
- return data
-
@dataclass(slots=True)
class Emulator:
- args: InitVar[Namespace]
+ args: InitVar[Arguments]
rom: InitVar[Path]
name: str = field(init=False)
@@ -115,12 +54,12 @@ def es_game_info(self) -> Mapping[str, str]:
return vals
- def __post_init__(self, args: Namespace, rom: Path, /) -> None:
+ def __post_init__(self, args: Arguments, rom: Path, /) -> None:
self.name = args.system
- self.game_info_xml = args.gameinfoxml
+ self.game_info_xml = str(args.gameinfoxml)
# read the configuration from the system name
- system_data = _load_system_config(args.system)
+ system_data = load_system_defaults(args.system)
# sanitize rule by EmulationStation
# see FileData::getConfigurationName() on batocera-emulationstation
@@ -239,7 +178,7 @@ def __post_init__(self, args: Namespace, rom: Path, /) -> None:
if args.state_filename is not None:
system_data['state_filename'] = args.state_filename
- self.config = SystemConfig(system_data)
+ self.config = SystemConfig(system_data, args.system)
render_data: dict[str, Any] = {}
if (shader_set := self.config.get('shaderset')) is not self.config.MISSING:
@@ -250,7 +189,7 @@ def __post_init__(self, args: Namespace, rom: Path, /) -> None:
if not rendering_defaults.exists():
rendering_defaults = BATOCERA_SHADERS / 'configs' / shader_set / 'rendering-defaults.yml'
- render_data = _load_defaults(
+ render_data = load_defaults(
args.system, rendering_defaults, rendering_defaults.with_name('rendering-defaults-arch.yml')
) or {}
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/batoceraPaths.py b/package/batocera/core/batocera-configgen/configgen/configgen/batoceraPaths.py
index d7f62677163..fb2b11b5551 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/batoceraPaths.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/batoceraPaths.py
@@ -1,14 +1,8 @@
from __future__ import annotations
from contextlib import contextmanager
-from pathlib import Path
from typing import IO, TYPE_CHECKING, Any, Final, overload
-if TYPE_CHECKING:
- from _typeshed import OpenBinaryModeUpdating, OpenBinaryModeWriting, OpenTextModeUpdating, OpenTextModeWriting
- from collections.abc import Generator
- from io import BufferedRandom, BufferedWriter, TextIOWrapper
-
from batocera_common.paths import (
BATOCERA_CONF as BATOCERA_CONF,
BATOCERA_SHARE_DIR as BATOCERA_SHARE_DIR,
@@ -25,40 +19,36 @@
SCREENSHOTS as SCREENSHOTS,
USERDATA as USERDATA,
)
+from batocera_launch.paths import (
+ BATOCERA_SHADERS as BATOCERA_SHADERS,
+ CONF_INIT as CONF_INIT,
+ DATAINIT_DIR as DATAINIT_DIR,
+ ES_GAMES_METADATA as ES_GAMES_METADATA,
+ ES_GUNS_ART_METADATA as ES_GUNS_ART_METADATA,
+ ES_GUNS_METADATA as ES_GUNS_METADATA,
+ ES_SETTINGS as ES_SETTINGS,
+ ES_WHEELS_METADATA as ES_WHEELS_METADATA,
+ EVMAPY as EVMAPY,
+ HOME_INIT as HOME_INIT,
+ SYSTEM_DECORATIONS as SYSTEM_DECORATIONS,
+ SYSTEM_ES_DIR,
+ SYSTEM_SCRIPTS as SYSTEM_SCRIPTS,
+ USER_DECORATIONS as USER_DECORATIONS,
+ USER_ES_DIR as USER_ES_DIR,
+ USER_SCRIPTS as USER_SCRIPTS,
+ USER_SHADERS as USER_SHADERS,
+ configure_emulator as configure_emulator,
+)
-DATAINIT_DIR: Final = BATOCERA_SHARE_DIR / 'datainit'
-
-HOME_INIT: Final = DATAINIT_DIR / 'system'
-CONF_INIT: Final = HOME_INIT / 'configs'
-
-EVMAPY: Final = CONFIGS / 'evmapy'
-
-USER_ES_DIR: Final = CONFIGS / 'emulationstation'
-BATOCERA_ES_DIR: Final = Path('/usr/share/emulationstation')
-CONFIGGEN_DATA_DIR: Final = Path('/usr/share/batocera/configgen/data')
-
-_ES_RESOURCES_DIR: Final = BATOCERA_ES_DIR / 'resources'
-
-ES_SETTINGS: Final = USER_ES_DIR / 'es_settings.cfg'
-ES_GUNS_METADATA: Final = _ES_RESOURCES_DIR / 'gungames.xml'
-ES_WHEELS_METADATA: Final = _ES_RESOURCES_DIR / 'wheelgames.xml'
-ES_GAMES_METADATA: Final = _ES_RESOURCES_DIR / 'gamesdb.xml'
-ES_GUNS_ART_METADATA: Final = CONFIGGEN_DATA_DIR / 'gamesbuttonsdb.xml'
+if TYPE_CHECKING:
+ from _typeshed import OpenBinaryModeUpdating, OpenBinaryModeWriting, OpenTextModeUpdating, OpenTextModeWriting
+ from collections.abc import Generator
+ from io import BufferedRandom, BufferedWriter, TextIOWrapper
+ from pathlib import Path
DEFAULTS_DIR: Final = BATOCERA_SHARE_DIR / 'configgen'
-
-USER_SHADERS: Final = USERDATA / 'shaders'
-BATOCERA_SHADERS: Final = BATOCERA_SHARE_DIR / 'shaders'
-
-USER_DECORATIONS: Final = USERDATA / 'decorations'
-SYSTEM_DECORATIONS: Final = DATAINIT_DIR / 'decorations'
-
-USER_SCRIPTS: Final = HOME / 'scripts'
-SYSTEM_SCRIPTS: Final = DEFAULTS_DIR / 'scripts'
-
-
-def configure_emulator(rom: Path, /) -> bool:
- return str(rom) == 'config'
+CONFIGGEN_DATA_DIR: Final = DEFAULTS_DIR / 'data'
+BATOCERA_ES_DIR: Final = SYSTEM_ES_DIR
def mkdir_if_not_exists(dir: Path, /) -> None:
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/config.py b/package/batocera/core/batocera-configgen/configgen/configgen/config.py
index 158764f4e11..15f88fe4458 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/config.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/config.py
@@ -128,6 +128,8 @@ def values(self):
@dataclass
class SystemConfig(Config):
+ system: str
+
@property
def emulator(self) -> str:
return self.data['emulator']
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/controller.py b/package/batocera/core/batocera-configgen/configgen/configgen/controller.py
index c6b47dbd17b..98b6f05c0b8 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/controller.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/controller.py
@@ -1,278 +1,9 @@
from __future__ import annotations
-import xml.etree.ElementTree as ET
-from collections.abc import Iterable, Mapping, Sequence
-from dataclasses import InitVar, dataclass, field, replace
-from pathlib import Path
-from typing import TYPE_CHECKING, Final, Literal, Self, TypedDict, Unpack, cast
-
-from .batoceraPaths import BATOCERA_ES_DIR, HOME, USER_ES_DIR
-from .exceptions import BatoceraException
-from .input import Input, InputDict, InputMapping
-
-if TYPE_CHECKING:
- from argparse import Namespace
-
-
-"""Default mapping of Batocera keys to SDL_GAMECONTROLLERCONFIG keys."""
-_DEFAULT_SDL_MAPPING: Final = {
- 'b': 'a',
- 'a': 'b',
- 'x': 'y',
- 'y': 'x',
- 'l2': 'lefttrigger',
- 'r2': 'righttrigger',
- 'l3': 'leftstick',
- 'r3': 'rightstick',
- 'pageup': 'leftshoulder',
- 'pagedown': 'rightshoulder',
- 'start': 'start',
- 'select': 'back',
- 'up': 'dpup',
- 'down': 'dpdown',
- 'left': 'dpleft',
- 'right': 'dpright',
- 'joystick1up': 'lefty',
- 'joystick1left': 'leftx',
- 'joystick2up': 'righty',
- 'joystick2left': 'rightx',
- 'hotkey': 'guide'
-}
-
-
-def _key_to_sdl_game_controller_config(keyname: str, input: Input, /) -> str | None:
- """
- Converts a key mapping to the SDL_GAMECONTROLLER format.
-
- Arguments:
- keyname: (str) SDL_GAMECONTROLLERCONFIG input name.
- input: (Input) input object.
- Returns:
- (str) SDL_GAMECONTROLLERCONFIG-formatted key mapping string.
- """
- if input.type == 'button':
- return f'{keyname}:b{input.id}'
-
- if input.type == 'hat':
- return f'{keyname}:h{input.id}.{input.value}'
-
- if input.type == 'axis':
- if 'joystick' in input.name:
- return f"{keyname}:a{input.id}{'~' if int(input.value) > 0 else ''}"
-
- if keyname in ('dpup', 'dpdown', 'dpleft', 'dpright'):
- return f"{keyname}:{'-' if int(input.value) < 0 else '+'}a{input.id}"
-
- if 'trigger' in keyname:
- return f"{keyname}:a{input.id}{'~' if int(input.value) < 0 else ''}"
-
- return f'{keyname}:a{input.id}'
-
- if input.type == 'key':
- return None
-
- raise BatoceraException(f'Unknown controller input type: {input.type!r}')
-
-
-def _find_input_config(roots: Iterable[ET.Element], name: str, guid: str, /) -> ET.Element:
- path = './inputConfig'
-
- for root in roots:
- element = root.find(f'{path}[@deviceGUID="{guid}"][@deviceName="{name}"]')
- if element is not None:
- return element
-
- for root in roots:
- element = root.find(f'{path}[@deviceGUID="{guid}"]')
- if element is not None:
- return element
-
- for root in roots:
- element = root.find(f'{path}[@deviceName="{name}"]')
- if element is not None:
- return element
-
- raise BatoceraException(f'Could not find controller data for "{name}" with GUID "{guid}"')
-
-
-class _RelaxedDict(TypedDict):
- centered: bool
- reversed: bool
-
-
-class _ControllerChanges(TypedDict, total=False):
- guid: str
- player_number: int
- index: int
- real_name: str
- device_path: str
- button_count: int
- hat_count: int
- axis_count: int
- physical_device_path: str | None
- physical_index: int | None
-
-
-@dataclass(slots=True, kw_only=True)
-class Controller:
- name: str
- type: Literal['keyboard', 'joystick']
- guid: str
- player_number: int # when this is filled out, it will start at 1
- index: int
- real_name: str
- device_path: str
- button_count: int
- hat_count: int
- axis_count: int
- physical_device_path: str | None = None
- physical_index: int | None = None
-
- inputs_: InitVar[InputMapping | Iterable[tuple[str, Input]] | None] = None
- inputs: InputDict = field(init=False)
-
- def __post_init__(self, inputs_: InputMapping | Iterable[tuple[str, Input]] | None, /) -> None:
- self.inputs = dict(inputs_) if inputs_ is not None else {}
-
- def replace(self, /, **changes: Unpack[_ControllerChanges]) -> Self:
- return replace(self, **changes, inputs_={name: input.replace() for name, input in self.inputs.items()})
-
- def generate_sdl_game_db_line(self, sdl_mapping: Mapping[str, str] = _DEFAULT_SDL_MAPPING, /, ignore_buttons: list[str] | None = None) -> str:
- """Returns an SDL_GAMECONTROLLERCONFIG-formatted string for the given configuration."""
- config = [self.guid, self.real_name.replace(",", "."), "platform:Linux"]
-
- def add_mapping(input: Input) -> None:
- key_name = sdl_mapping.get(input.name, None)
- if key_name is None:
- return
- sdl_config = _key_to_sdl_game_controller_config(key_name, input)
- if sdl_config is not None:
- config.append(sdl_config)
-
- # "hotkey" is often mapped to an existing button but such a duplicate mapping
- # confuses SDL apps. We add "hotkey" mapping only if its target isn't also mapped elsewhere.
- hotkey_input: Input | None = None
- mapped_button_ids: set[str] = set()
-
- for input in self.inputs.values():
- if input.name is None: # pragma: no cover
- continue
- if ignore_buttons is not None and input.name in ignore_buttons:
- continue
- if input.name == 'hotkey':
- hotkey_input = input
- continue
- if input.type == 'button':
- mapped_button_ids.add(input.id)
-
- add_mapping(input)
-
- if hotkey_input is not None and hotkey_input.id not in mapped_button_ids:
- add_mapping(hotkey_input)
-
- config.append('')
-
- return ','.join(config)
-
- def get_mapping_axis_relaxed_values(self) -> dict[str, _RelaxedDict]:
- import evdev
-
- # read the sdl2 cache if possible for axis
- cache_file = Path(HOME / ".sdl2" / f"{self.guid}_{self.name}.cache")
- if not cache_file.exists():
- return {}
-
- cache_content = cache_file.read_text(encoding="utf-8").splitlines()
- n = int(cache_content[0]) # number of lines of the cache
-
- relaxed_values: list[int] = [int(cache_content[i]) for i in range(1, n+1)]
-
- # get full list of axis (in case one is not used in es)
- caps = evdev.InputDevice(self.device_path).capabilities()
- code_values: dict[int, int] = {}
- i = 0
- for code, _ in caps[evdev.ecodes.EV_ABS]:
- if code < evdev.ecodes.ABS_HAT0X:
- code_values[code] = relaxed_values[i]
- i = i+1
-
- # dict with es input names
- res: dict[str, _RelaxedDict] = {}
- for x, input in self.inputs.items():
- if input.type == "axis":
- # sdl values : from -32000 to 32000 / do not put < 0 cause a wheel/pad could be not correctly centered
- # 3 possible initial positions <1----------------|-------2-------|----------------3>
- if (val := code_values.get(int(cast('str', input.code)))) is not None:
- res[x] = { "centered": val > -4000 and val < 4000, "reversed": val > 4000 }
- else:
- res[x] = { "centered": True, "reversed": False }
- return res
-
- # Create a controller array with the player id as a key
- @classmethod
- def load_for_players(cls, max_players: int, args: Namespace, /) -> ControllerList:
- cfg_roots = [
- ET.parse(conffile).getroot()
- for conffile in (USER_ES_DIR / 'es_input.cfg', BATOCERA_ES_DIR / 'es_input.cfg')
- if conffile.exists()
- ]
-
- return [
- controller
- for player_number in range(1, max_players + 1)
- if (controller := cls._find_best_controller(cfg_roots, args, player_number)) is not None
- ]
-
- @classmethod
- def _find_best_controller(
- cls, roots: Iterable[ET.Element], args: Namespace, player_number: int, /,
- ) -> Controller | None:
- index: int | None = getattr(args, f'p{player_number}index')
-
- if index is None:
- return None
-
- guid: str = getattr(args, f'p{player_number}guid')
- real_name: str = getattr(args, f'p{player_number}name')
-
- input_config = _find_input_config(roots, real_name, guid)
- return cls(
- name=cast('str', input_config.get("deviceName")),
- type=cast('Literal["keyboard", "joystick"]', input_config.get("type")),
- guid=guid,
- inputs_=Input.from_parent_element(input_config),
- player_number=player_number,
- index=index,
- real_name=real_name,
- device_path=getattr(args, f'p{player_number}devicepath'),
- button_count=getattr(args, f'p{player_number}nbbuttons'),
- hat_count=getattr(args, f'p{player_number}nbhats'),
- axis_count=getattr(args, f'p{player_number}nbaxes'),
- )
-
- @staticmethod
- def find_player_number(controllers: Controllers, player_number: int, /) -> Controller | None:
- for controller in controllers:
- if controller.player_number == player_number:
- return controller
-
- return None
-
-
-def generate_sdl_game_controller_config(controllers: Controllers, /, ignore_buttons: list[str] | None = None) -> str:
- return "\n".join(controller.generate_sdl_game_db_line(ignore_buttons = ignore_buttons) for controller in controllers)
-
-
-def write_sdl_controller_db(
- controllers: Controllers, outputFile: str | Path = "/tmp/gamecontrollerdb.txt", /,
-) -> Path:
- outputFile = Path(outputFile)
-
- with outputFile.open("w") as text_file:
- text_file.write(generate_sdl_game_controller_config(controllers))
-
- return outputFile
-
-
-type Controllers = Sequence[Controller]
-type ControllerList = list[Controller]
+from batocera_launch.devices.controller import (
+ Controller as Controller,
+ ControllerList as ControllerList,
+ Controllers as Controllers,
+ generate_sdl_game_controller_config as generate_sdl_game_controller_config,
+ write_sdl_controller_db as write_sdl_controller_db,
+)
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py b/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py
index 17562319ec2..2cdba56599b 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py
@@ -1,16 +1,8 @@
#!/usr/bin/env python
-# ruff: noqa: E402
from __future__ import annotations
-from batocera_common.paths import LOGS
-
-from . import profiler
-
-profiler.start()
-
### import always needed ###
-import argparse
import contextlib
import ctypes
import json
@@ -22,22 +14,22 @@
import time
from copy import deepcopy
from pathlib import Path
-from sys import exit
-from typing import TYPE_CHECKING, Any, cast
+from typing import IO, TYPE_CHECKING, Any, cast
import pyudev
import sdl2
-from .batoceraPaths import BATOCERA_SHARE_DIR, ES_GAMES_METADATA, SAVES, SYSTEM_SCRIPTS, USER_SCRIPTS
+from batocera_common.paths import LOGS
+
+from .batoceraPaths import ES_GAMES_METADATA, SAVES, SYSTEM_SCRIPTS, USER_SCRIPTS
from .controller import Controller
from .Emulator import Emulator
-from .exceptions import BadCommandLineArguments, BaseBatoceraException, BatoceraException, UnexpectedEmulatorExit
+from .exceptions import BadCommandLineArguments, UnexpectedEmulatorExit
from .generators import get_generator
from .gun import Gun
from .utils import bezels as bezelsUtil, metadata, videoMode, wheelsUtils
from .utils.evmapy import evmapy
from .utils.hotkeygen import set_hotkeygen_context
-from .utils.logger import setup_logging
from .utils.overlayfs import mount_overlayfs
from .utils.squashfs import mount_squashfs
@@ -45,11 +37,15 @@
from collections.abc import Iterable
from types import FrameType
+ from batocera_launch.cli.arguments import Arguments
+ from batocera_launch.profiler import Profiler
+
from .Command import Command
from .generators.Generator import Generator
from .types import Resolution
_logger = logging.getLogger(__name__)
+_emulator_logger = logging.getLogger('emulator')
# A lock to safely modify the active controller list from multiple threads
_player_controllers_lock = threading.Lock()
@@ -58,20 +54,31 @@
# Global reference to the evmapy configurator instance
_evmapy_instance = None
-def main(args: argparse.Namespace, maxnbplayers: int) -> int:
+def signal_handler(signal: int, frame: FrameType | None):
+ global proc
+ _logger.debug('Exiting')
+ if proc:
+ _logger.debug('killing proc')
+ proc.kill()
+
+def main(args: Arguments, profiler: Profiler, /) -> int:
+ global proc
+ proc = None
+ signal.signal(signal.SIGINT, signal_handler)
+
original_rom = args.rom
# squashfs roms if squashed
if original_rom.suffix == ".squashfs":
with mount_squashfs(original_rom) as squash_rom:
- return start_rom(args, maxnbplayers, squash_rom, original_rom)
+ return start_rom(args, profiler, squash_rom, original_rom)
else:
- return start_rom(args, maxnbplayers, original_rom, original_rom)
+ return start_rom(args, profiler, original_rom, original_rom)
-def start_rom(args: argparse.Namespace, maxnbplayers: int, rom: Path, original_rom: Path) -> int:
+def start_rom(args: Arguments, profiler: Profiler, rom: Path, original_rom: Path) -> int:
global _active_player_controllers, _evmapy_instance
- player_controllers = Controller.load_for_players(maxnbplayers, args)
+ player_controllers = Controller.load_for_players(args.players)
# Initialize the global state with the initial controller list
with _player_controllers_lock:
@@ -98,7 +105,7 @@ def start_rom(args: argparse.Namespace, maxnbplayers: int, rom: Path, original_r
# metadata
md = metadata.get_games_meta_data(ES_GAMES_METADATA, systemName, rom)
- guns = Gun.get_and_precalibrate_all(system, rom)
+ guns = Gun.get_and_precalibrate_all(system.config, rom)
with wheelsUtils.configure_wheels(player_controllers, system, md) as (player_controllers, wheels):
# find the generator
@@ -500,7 +507,7 @@ def hudConfig_protectStr(string: str | Path | None) -> str:
def getHudConfig(
system: Emulator,
- systemName: str,
+ systemName: str | None,
emulator: str,
core: str,
rom: Path,
@@ -703,6 +710,10 @@ def _controller_monitor_thread():
if we_initialized_sdl:
sdl2.SDL_QuitSubSystem(sdl2.SDL_INIT_JOYSTICK)
+def _log_emulator_stream(stream: IO[bytes], level: int, prefix: str, /) -> None:
+ for raw in iter(stream.readline, b''):
+ _emulator_logger.log(level, '%s %s', prefix, raw.decode(errors='backslashreplace').rstrip())
+
def runCommand(command: Command) -> int:
global proc
@@ -719,13 +730,19 @@ def runCommand(command: Command) -> int:
raise BadCommandLineArguments
proc = subprocess.Popen(["nice", "-n", "-4", *command.array], env=command.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+ threads = [
+ threading.Thread(target=_log_emulator_stream, args=(proc.stdout, logging.DEBUG, "[stdout]")),
+ threading.Thread(target=_log_emulator_stream, args=(proc.stderr, logging.ERROR, "[stderr]")),
+ ]
+
+ for thread in threads:
+ thread.start()
+
exitcode = 0
try:
- out, err = proc.communicate()
- exitcode = proc.returncode
- _logger.debug(out.decode(errors='backslashreplace'))
- _logger.error(err.decode(errors='backslashreplace'))
+ exitcode = proc.wait()
except BrokenPipeError:
# Seeing BrokenPipeError? This is probably caused by head truncating output in the front-end
# Examine es-core/src/platform.cpp::runSystemCommand for additional context
@@ -734,94 +751,12 @@ def runCommand(command: Command) -> int:
_logger.error("emulator exited")
raise UnexpectedEmulatorExit from e
+ finally:
+ for thread in threads:
+ thread.join()
return exitcode
-def signal_handler(signal: int, frame: FrameType | None):
- global proc
- _logger.debug('Exiting')
- if proc:
- _logger.debug('killing proc')
- proc.kill()
-
-def launch() -> None:
- with setup_logging():
- global proc
- proc = None
- signal.signal(signal.SIGINT, signal_handler)
-
- batocera_version = 'UNKNOWN'
- if (version_file := BATOCERA_SHARE_DIR / 'batocera.version').exists():
- batocera_version = version_file.read_text().strip()
- _logger.info('Batocera version: %s', batocera_version)
-
- parser = argparse.ArgumentParser(description='emulator-launcher script')
-
- maxnbplayers = 8
- for p in range(1, maxnbplayers+1):
- parser.add_argument(f"-p{p}index" , help=f"player{p} controller index" , type=int, required=False)
- parser.add_argument(f"-p{p}guid" , help=f"player{p} controller SDL2 guid" , type=str, required=False)
- parser.add_argument(f"-p{p}name" , help=f"player{p} controller name" , type=str, required=False)
- parser.add_argument(f"-p{p}devicepath", help=f"player{p} controller device" , type=str, required=False)
- parser.add_argument(f"-p{p}nbbuttons" , help=f"player{p} controller number of buttons", type=int, required=False)
- parser.add_argument(f"-p{p}nbhats" , help=f"player{p} controller number of hats" , type=int, required=False)
- parser.add_argument(f"-p{p}nbaxes" , help=f"player{p} controller number of axes" , type=int, required=False)
-
- parser.add_argument("-system", help="select the system to launch", type=str, required=True)
- parser.add_argument("-rom", help="rom absolute path", type=Path, required=True)
- parser.add_argument("-emulator", help="force emulator", type=str, required=False)
- parser.add_argument("-core", help="force emulator core", type=str, required=False)
- parser.add_argument("-netplaymode", help="host/client", type=str, required=False)
- parser.add_argument("-netplaypass", help="enable spectator mode", type=str, required=False)
- parser.add_argument("-netplayip", help="remote ip", type=str, required=False)
- parser.add_argument("-netplayport", help="remote port", type=str, required=False)
- parser.add_argument("-netplaysession", help="netplay session", type=str, required=False)
- parser.add_argument("-state_slot", help="state slot", type=str, required=False)
- parser.add_argument("-state_filename", help="state filename", type=str, required=False)
- parser.add_argument("-autosave", help="autosave", type=str, required=False)
- parser.add_argument("-systemname", help="system fancy name", type=str, required=False)
- parser.add_argument("-gameinfoxml", help="game info xml", type=str, nargs='?', default='/dev/null', required=False)
- parser.add_argument("-lightgun", help="configure lightguns", action="store_true")
- parser.add_argument("-wheel", help="configure wheel", action="store_true")
- parser.add_argument("-trackball", help="configure trackball", action="store_true")
- parser.add_argument("-spinner", help="configure spinner", action="store_true")
-
- args = parser.parse_args()
- exitcode = 0
- try:
- exitcode = main(args, maxnbplayers)
- except BaseBatoceraException as e:
- _logger.exception("configgen exception: ")
- exitcode = e.exit_code
-
- if isinstance(e, BatoceraException):
- Path('/tmp/launch_error.log').write_text(e.args[0])
- except Exception:
- _logger.exception("configgen exception: ")
-
- profiler.stop()
-
- time.sleep(1) # this seems to be required so that the gpu memory is restituated and available for es
-
- if exitcode < 0:
- signal_number = exitcode * -1
-
- if signal_number < signal.NSIG:
- signal_description = signal.strsignal(signal_number)
-
- if signal_description and ':' not in signal_description:
- signal_description = f'{signal_description}: {signal_number}'
-
- _logger.debug("Emulator terminated by signal (%s)", signal_description)
- exitcode = 0
-
- _logger.debug("Exiting configgen with status %s", exitcode)
-
- exit(exitcode)
-
-if __name__ == '__main__':
- launch()
-
# Local Variables:
# tab-width:4
# indent-tabs-mode:nil
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastGenerator.py b/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastGenerator.py
index ec55ef1fb98..a05224c81cb 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastGenerator.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastGenerator.py
@@ -10,7 +10,7 @@
from ...controller import generate_sdl_game_controller_config
from ..Generator import Generator
from . import flycastControllers
-from .flycastPaths import FLYCAST_BIOS, FLYCAST_CONFIG, FLYCAST_SAVES, FLYCAST_VMU_BLANK, FLYCAST_VMUA1, FLYCAST_VMUA2
+from .flycastPaths import FLYCAST_BIOS, FLYCAST_CONFIG, FLYCAST_SAVES, FLYCAST_VMUA1, FLYCAST_VMUA2
if TYPE_CHECKING:
from ...types import HotkeysContext
@@ -176,12 +176,18 @@ def generate(self, system, rom, playersControllers, metadata, guns, wheels, game
# internal config
mkdir_if_not_exists(FLYCAST_SAVES)
- # vmuA1
- if not FLYCAST_VMUA1.is_file():
- copyfile(FLYCAST_VMU_BLANK, FLYCAST_VMUA1)
- # vmuA2
- if not FLYCAST_VMUA2.is_file():
- copyfile(FLYCAST_VMU_BLANK, FLYCAST_VMUA2)
+ if not FLYCAST_VMUA1.is_file() or not FLYCAST_VMUA2.is_file():
+ from importlib import resources
+
+ from batocera_launch_flycast.emulator import VMU_BLANK
+
+ with resources.as_file(VMU_BLANK) as vmu_blank:
+ # vmuA1
+ if not FLYCAST_VMUA1.is_file():
+ copyfile(vmu_blank, FLYCAST_VMUA1)
+ # vmuA2
+ if not FLYCAST_VMUA2.is_file():
+ copyfile(vmu_blank, FLYCAST_VMUA2)
# the command to run
commandArray = ['/usr/bin/flycast', rom]
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastPaths.py b/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastPaths.py
index 5c954f4add8..820814eac87 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastPaths.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/generators/flycast/flycastPaths.py
@@ -2,7 +2,7 @@
from typing import Final
-from ...batoceraPaths import BIOS, CONFIGS, DEFAULTS_DIR, SAVES
+from ...batoceraPaths import BIOS, CONFIGS, SAVES
FLYCAST_CONFIG_DIR: Final = CONFIGS / 'flycast'
FLYCAST_MAPPING: Final = FLYCAST_CONFIG_DIR / 'mappings'
@@ -10,6 +10,5 @@
FLYCAST_SAVES: Final = SAVES / 'dreamcast' / 'flycast'
FLYCAST_BIOS: Final = BIOS / 'dc'
-FLYCAST_VMU_BLANK: Final = DEFAULTS_DIR / 'data' / 'dreamcast' / 'vmu_save_blank.bin'
FLYCAST_VMUA1: Final = FLYCAST_SAVES / 'vmu_save_A1.bin'
FLYCAST_VMUA2: Final = FLYCAST_SAVES / 'vmu_save_A2.bin'
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/gun.py b/package/batocera/core/batocera-configgen/configgen/configgen/gun.py
index 7048bdec712..1db5ac36303 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/gun.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/gun.py
@@ -1,208 +1,8 @@
from __future__ import annotations
-import logging
-import re
-import shutil
-from collections.abc import Mapping, Sequence
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import TYPE_CHECKING, ClassVar, Final, cast
-
-from .batoceraPaths import BATOCERA_SHARE_DIR, CONFIGS, SAVES, mkdir_if_not_exists
-
-if TYPE_CHECKING:
- from .Emulator import Emulator
-
-_logger = logging.getLogger(__name__)
-_input_re: Final = re.compile(r'^/dev/input/event([0-9]*)$')
-_PRECALIBRATION_DIR: Final = BATOCERA_SHARE_DIR / 'guns-precalibrations'
-
-
-def _copy_file(src: Path, dst: Path) -> None:
- if src.exists() and not dst.exists():
- mkdir_if_not_exists(dst.parent)
- shutil.copyfile(src, dst)
-
-
-def _copy_dir(src: Path, dst: Path) -> None:
- if src.exists() and not dst.exists():
- mkdir_if_not_exists(dst.parent)
- shutil.copytree(src, dst)
-
-
-def _copy_files_in_dir(srcdir: Path, dstdir: Path, startWith: str, endWith: str) -> None:
- for src in srcdir.iterdir():
- if src.name.startswith(startWith): # and src.endswith(endswith):
- _copy_file(src, dstdir / src.name)
-
-
-@dataclass(slots=True, kw_only=True)
-class Gun:
- mouse_buttons_to_code: ClassVar[Mapping[str, int]]
-
- node: str
- mouse_index: int
- needs_cross: bool
- needs_borders: bool
- name: str
- buttons: list[str]
- button_map: Mapping[str, int] = field(init=False)
-
- def __post_init__(self) -> None:
- if not hasattr(Gun, '_mouse_buttons_to_code'):
- # delay initialization of the map so we don't have
- # to import evdev until we need it
- import evdev
-
- Gun.mouse_buttons_to_code = {
- 'left': evdev.ecodes.BTN_LEFT,
- 'right': evdev.ecodes.BTN_RIGHT,
- 'middle': evdev.ecodes.BTN_MIDDLE,
- '1': evdev.ecodes.BTN_1,
- '2': evdev.ecodes.BTN_2,
- '3': evdev.ecodes.BTN_3,
- '4': evdev.ecodes.BTN_4,
- '5': evdev.ecodes.BTN_5,
- '6': evdev.ecodes.BTN_6,
- '7': evdev.ecodes.BTN_7,
- '8': evdev.ecodes.BTN_8,
- }
-
- self.button_map = {
- button: self.mouse_buttons_to_code[button]
- for button in self.buttons
- if button in self.mouse_buttons_to_code
- }
-
- @staticmethod
- def get_all() -> GunList:
- import evdev
- import pyudev
-
- guns: GunList = []
- context = pyudev.Context()
-
- # guns are mouses, just filter on them
- mouses = {
- int(match.group(1)): mouse
- for mouse in context.list_devices(subsystem='input')
- if mouse.device_node is not None
- and (match := _input_re.match(mouse.device_node)) is not None
- and mouse.properties.get('ID_INPUT_MOUSE') == '1'
- }
-
- mouse_code_to_button = {
- evdev.ecodes.BTN_LEFT: 'left',
- evdev.ecodes.BTN_RIGHT: 'right',
- evdev.ecodes.BTN_MIDDLE: 'middle',
- evdev.ecodes.BTN_1: '1',
- evdev.ecodes.BTN_2: '2',
- evdev.ecodes.BTN_3: '3',
- evdev.ecodes.BTN_4: '4',
- evdev.ecodes.BTN_5: '5',
- evdev.ecodes.BTN_6: '6',
- evdev.ecodes.BTN_7: '7',
- evdev.ecodes.BTN_8: '8',
- }
- mouse_button_codes = set(mouse_code_to_button.keys())
-
- for mouse_index, (_, mouse) in enumerate(sorted(mouses.items(), key=lambda item: item[0])):
- _logger.info('found mouse %s at %s with id_mouse=%s', mouse_index, mouse.device_node, mouse_index)
- if mouse.properties.get('ID_INPUT_GUN') != '1':
- continue
-
- device = evdev.InputDevice(cast('str', mouse.device_node))
- device_codes = set(device.capabilities()[evdev.ecodes.EV_KEY]) & mouse_button_codes
-
- gun = Gun(
- node=cast('str', mouse.device_node),
- # retroarch uses mouse indexes into configuration files using ID_INPUT_MOUSE
- # (TOUCHPAD are listed after mouses)
- mouse_index=mouse_index,
- needs_cross=mouse.properties.get('ID_INPUT_GUN_NEED_CROSS') == '1',
- needs_borders=mouse.properties.get('ID_INPUT_GUN_NEED_BORDERS') == '1',
- name=device.name,
- buttons=[button for code, button in mouse_code_to_button.items() if code in device_codes],
- )
- guns.append(gun)
- _logger.info(
- 'found gun %s at %s with id_mouse=%s (%s)', len(guns) - 1, mouse.device_node, mouse_index, gun.name
- )
-
- if not guns:
- _logger.info('no gun found')
-
- return guns
-
- @classmethod
- def get_and_precalibrate_all(cls, system: Emulator, rom: str | Path, /) -> GunList:
- if not system.config.use_guns:
- _logger.info('guns disabled.')
- return []
-
- dir = _PRECALIBRATION_DIR / system.name
-
- if dir.exists():
- rom = Path(rom)
- emulator = system.config.emulator
- core = cast('str | None', system.config.get('core'))
-
- if system.name == 'atomiswave':
- for suffix in ['nvmem', 'nvmem2']:
- src = dir / 'reicast' / f'{rom.name}.{suffix}'
- dst = SAVES / 'atomiswave' / 'reicast' / f'{rom.name}.{suffix}'
- _copy_file(src, dst)
-
- elif system.name == 'mame':
- target_dir: str | None = None
- if emulator == 'mame':
- target_dir = 'mame'
- elif emulator == 'libretro':
- if core == 'mame078plus':
- target_dir = 'mame/mame2003-plus'
- elif core == 'mame':
- target_dir = 'mame/mame'
-
- if target_dir is not None:
- src = dir / 'nvram' / rom.stem
- dst = SAVES / target_dir / 'nvram' / rom.stem
- _copy_dir(src, dst)
- srcdir = dir / 'diff'
- dstdir = SAVES / target_dir / 'diff'
- _copy_files_in_dir(srcdir, dstdir, rom.stem + '_', '.dif')
-
- elif system.name == 'model2':
- src = dir / 'NVDATA' / f'{rom.name}.DAT'
- dst = SAVES / 'model2' / 'NVDATA' / f'{rom.name}.DAT'
- _copy_file(src, dst)
-
- elif system.name == 'naomi':
- for suffix in ['nvmem', 'eeprom']:
- src = dir / 'reicast' / f'{rom.name}.{suffix}'
- dst = SAVES / 'naomi' / 'reicast' / f'{rom.name}.{suffix}'
- _copy_file(src, dst)
-
- elif system.name == 'supermodel':
- src = dir / 'NVDATA' / f'{rom.stem}.nv'
- dst = SAVES / 'supermodel' / 'NVDATA' / f'{rom.stem}.nv'
- _copy_file(src, dst)
-
- elif system.name == 'namco2x6': # noqa: SIM102
- if emulator == 'play':
- src = dir / 'play' / rom.stem
- dst = CONFIGS / 'play' / 'Play Data Files' / 'arcadesaves' / f'{rom.stem}.backupram'
- _copy_file(src, dst)
-
- return cls.get_all()
-
-
-def guns_need_crosses(guns: Guns) -> bool:
- # no gun, enable the cross for joysticks, mouses...
- if not guns:
- return True
-
- return any(gun.needs_cross for gun in guns)
-
-
-type Guns = Sequence[Gun]
-type GunList = list[Gun]
+from batocera_launch.devices.gun import (
+ Gun as Gun,
+ GunList as GunList,
+ Guns as Guns,
+ guns_need_crosses as guns_need_crosses,
+)
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/input.py b/package/batocera/core/batocera-configgen/configgen/configgen/input.py
index 387a69ff680..9821c9dab3f 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/input.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/input.py
@@ -1,48 +1,3 @@
from __future__ import annotations
-from collections.abc import Iterator, Mapping
-from dataclasses import dataclass, replace
-from typing import TYPE_CHECKING, Self, TypedDict, Unpack, cast
-
-if TYPE_CHECKING:
- import xml.etree.ElementTree as ET
-
-
-class _InputChanges(TypedDict, total=False):
- name: str
- type: str
- id: str
- value: str
- code: str | None
-
-
-@dataclass(slots=True, kw_only=True)
-class Input:
- name: str
- type: str
- id: str
- value: str
- code: str | None = None
-
- def replace(self, /, **changes: Unpack[_InputChanges]) -> Self:
- return replace(self, **changes)
-
- @classmethod
- def from_element(cls, element: ET.Element, /) -> Self:
- return cls(
- name=cast("str", element.get("name")),
- type=cast("str", element.get("type")),
- id=cast("str", element.get("id")),
- value=cast("str", element.get("value")),
- code=element.get("code")
- )
-
- @classmethod
- def from_parent_element(cls, parent_element: ET.Element, /) -> Iterator[tuple[str, Self]]:
- for element in parent_element.iterfind('./input'):
- input = cls.from_element(element)
- yield input.name, input
-
-
-type InputMapping = Mapping[str, Input]
-type InputDict = dict[str, Input]
+from batocera_launch.devices.input import Input as Input, InputDict as InputDict, InputMapping as InputMapping
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/launch.py b/package/batocera/core/batocera-configgen/configgen/configgen/launch.py
new file mode 100644
index 00000000000..ed789f2d626
--- /dev/null
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/launch.py
@@ -0,0 +1,173 @@
+from __future__ import annotations
+
+from collections import ChainMap
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Self, cast
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import (
+ Command,
+ Emulator,
+ HotkeysContext,
+ SystemConfig,
+)
+
+from .config import Config, SystemConfig as _SystemConfig
+from .Emulator import Emulator as _System
+from .generators.importer import get_generator
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+ from batocera_launch.cli.arguments import Arguments
+ from batocera_launch.devices.device import DeviceInfo
+
+ from .generators.Generator import Generator
+ from .gun import Guns
+ from .types import DeviceInfo as _DeviceInfo
+
+def _convert_device_info(device_info: DeviceInfo, /) -> _DeviceInfo:
+ result: _DeviceInfo = {
+ 'eventId': device_info.event_id,
+ 'sysfs_path': device_info.sysfs_path,
+ 'isJoystick': device_info.is_joystick,
+ 'isWheel': device_info.is_wheel,
+ 'isMouse': device_info.is_mouse,
+ 'associatedDevices': device_info.associated_devices,
+ 'joystick_index': device_info.joystick_index,
+ 'mouse_index': device_info.mouse_index,
+ }
+
+ if device_info.wheel_rotation is not None:
+ result['wheel_rotation'] = device_info.wheel_rotation
+
+ return result
+
+@dataclass
+class GeneratorSystemConfig(_SystemConfig):
+ @classmethod
+ def from_launch(cls, config: SystemConfig, /) -> Self:
+ overlay: dict[str, Any] = {
+ 'emulator': config.emulator,
+ 'emulator-forced': config.emulator_forced,
+ 'core': config.core,
+ 'core-forced': config.core_forced,
+ 'uimode': config.ui_mode,
+ 'showFPS': config.show_fps,
+ 'use_guns': config.use_guns,
+ 'use_wheels': config.use_wheels,
+ }
+
+ if config.netplay_mode is not None:
+ overlay['netplay.mode'] = config.netplay_mode
+
+ if config.netplay_password is not None:
+ overlay['netplay.password'] = config.netplay_password
+
+ if config.netplay_server_ip is not None:
+ overlay['netplay.server.ip'] = config.netplay_server_ip
+
+ if config.netplay_server_port is not None:
+ overlay['netplay.server.port'] = config.netplay_server_port
+
+ if config.netplay_server_session is not None:
+ overlay['netplay.server.session'] = config.netplay_server_session
+
+ if config.state_slot is not None:
+ overlay['state_slot'] = config.state_slot
+
+ if config.autosave is not None:
+ overlay['autosave'] = config.autosave
+
+ if config.state_filename is not None:
+ overlay['state_filename'] = config.state_filename
+
+ return cls(cast('dict[str, Any]', ChainMap(overlay, config.data)), config.cli_args.system)
+
+@dataclass(slots=True)
+class GeneratorSystem(_System):
+ launch_emulator: Emulator
+
+ @property
+ def es_game_info(self) -> Mapping[str, str]:
+ return self.launch_emulator.game_info
+
+ def __post_init__(self, args: Arguments, rom: Path, /) -> None:
+ self.name = self.launch_emulator.config.cli_args.system
+ self.game_info_xml = str(self.launch_emulator.config.cli_args.gameinfoxml)
+ self.config = GeneratorSystemConfig.from_launch(self.launch_emulator.config)
+ self.renderconfig = Config(dict(self.launch_emulator.config.render_config.data))
+
+ def guns_borders_size_name(self, guns: Guns) -> str | None:
+ return self.launch_emulator.guns_borders_size
+
+ def guns_border_ratio_type(self, guns: Guns) -> str | None:
+ return self.launch_emulator.guns_border_ratio
+
+@cached_dataclass
+class GeneratorEmulator(Emulator):
+ # These are set in __aenter__
+ generator: Generator = field(init=False)
+ configgen_system: GeneratorSystem = field(init=False)
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return self.generator.getHotkeysContext()
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.generator.executionDirectory(self.configgen_system.config, self.rom)
+
+ @property
+ def target_video_mode(self) -> str:
+ return self.generator.getResolutionMode(self.configgen_system.config) or 'default'
+
+ @property
+ def needs_mouse(self) -> bool:
+ return self.generator.getMouseMode(self.configgen_system.config, self.rom)
+
+ @property
+ def handles_bezels(self) -> bool:
+ return self.generator.supportsInternalBezels()
+
+ @property
+ def handles_hud(self) -> bool:
+ return self.generator.hasInternalMangoHUDCall()
+
+ @property
+ def needs_overlayfs(self) -> bool:
+ return self.generator.writesToRom(self.configgen_system.config)
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return self.generator.getInGameRatio(
+ self.configgen_system.config,
+ {
+ 'width': self.resolution.width,
+ 'height': self.resolution.height,
+ },
+ self.rom,
+ )
+
+ async def __aenter__(self) -> Self:
+ self.generator = get_generator(self.config.emulator, self.config.core)
+ self.configgen_system = GeneratorSystem(self.config.cli_args, self.config.cli_args.rom, self)
+
+ return await super().__aenter__()
+
+ async def configure(self) -> Command:
+ command = self.generator.generate(
+ self.configgen_system,
+ Path(self.rom),
+ self.controllers,
+ self.metadata,
+ self.guns,
+ {key: _convert_device_info(wheel) for key, wheel in self.wheels.items()},
+ asdict(self.resolution), # pyright: ignore
+ )
+
+ return Command(
+ command.array,
+ command.env,
+ )
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/utils/videoMode.py b/package/batocera/core/batocera-configgen/configgen/configgen/utils/videoMode.py
index 17400e01874..943d56531f9 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/utils/videoMode.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/utils/videoMode.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import csv
import logging
import re
import subprocess
@@ -9,7 +8,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Final
-from ..batoceraPaths import DEFAULTS_DIR
+from batocera_launch import get_decoration_id
+
from ..exceptions import BatoceraException
if TYPE_CHECKING:
@@ -215,27 +215,7 @@ def getAltDecoration(systemName: str, rom: str | Path, emulator: str) -> str:
if emulator not in [ 'mame', 'retroarch' ]:
return "standalone"
- if systemName not in [ 'lynx', 'wswan', 'wswanc', 'mame', 'fbneo', 'naomi', 'atomiswave', 'nds', '3ds', 'vectrex', 'dice' ]:
- return "0"
-
- # Look for external file, exit if not set up
- specialFile = DEFAULTS_DIR / 'data' / 'special' / f'{systemName}.csv'
- if not specialFile.exists():
- return "0"
-
- romCompare = Path(rom).stem.casefold()
-
- # Load the file, read it in
- # Each file will be a csv with each row being the standard (ie No-Intro) filename, angle of rotation (90 or 270)
- # Case indifferent, rom file name and filenames in list will be folded
- openFile = specialFile.open('r')
- with openFile:
- specialList = csv.reader(openFile, delimiter=';')
- for row in specialList:
- if row[0].casefold() == romCompare:
- return str(row[1])
-
- return "0"
+ return get_decoration_id(systemName, Path(rom).stem)
def configureWindow(screens, identifier=None, title=None, output=None, toogleFullscreen=None):
# default to the primary screen
diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/utils/vulkan.py b/package/batocera/core/batocera-configgen/configgen/configgen/utils/vulkan.py
index 6aea1341242..9c839c90fed 100644
--- a/package/batocera/core/batocera-configgen/configgen/configgen/utils/vulkan.py
+++ b/package/batocera/core/batocera-configgen/configgen/configgen/utils/vulkan.py
@@ -1,73 +1,11 @@
from __future__ import annotations
-import logging
-import subprocess
-from pathlib import Path
-from typing import Final
-
-_logger: Final = logging.getLogger(__name__)
-
-_BATOCERA_VULKAN: Final = Path('/usr/bin/batocera-vulkan')
-
-
-def is_available() -> bool:
- try:
- return subprocess.check_output([_BATOCERA_VULKAN, 'hasVulkan'], text=True).strip() == 'true'
- except subprocess.CalledProcessError:
- _logger.exception('Error checking for Vulkan driver')
-
- return False
-
-
-def has_discrete_gpu() -> bool:
- try:
- return subprocess.check_output([_BATOCERA_VULKAN, 'hasDiscrete'], text=True).strip() == 'true'
- except subprocess.CalledProcessError:
- _logger.exception('Error checking for discrete GPU.')
-
- return False
-
-
-def get_discrete_gpu_index() -> str | None:
- try:
- return subprocess.check_output([_BATOCERA_VULKAN, 'discreteIndex'], text=True).strip() or None
- except subprocess.CalledProcessError:
- _logger.exception('Error getting discrete GPU index')
-
- return None
-
-
-def get_discrete_gpu_name() -> str | None:
- try:
- return subprocess.check_output([_BATOCERA_VULKAN, 'discreteName'], text=True).strip() or None
- except subprocess.CalledProcessError:
- _logger.exception('Error getting discrete GPU Name')
-
- return None
-
-
-def get_default_gpu_name() -> str | None:
- try:
- return subprocess.check_output([_BATOCERA_VULKAN, 'defaultName'], text=True).strip() or None
- except subprocess.CalledProcessError:
- _logger.exception('Error getting default GPU Name')
-
- return None
-
-
-def get_discrete_gpu_uuid() -> str | None:
- try:
- return subprocess.check_output([_BATOCERA_VULKAN, 'discreteUUID'], text=True).strip() or None
- except subprocess.CalledProcessError:
- _logger.exception('Error getting discrete GPU UUID')
-
- return None
-
-
-def get_version() -> str:
- try:
- return subprocess.check_output([_BATOCERA_VULKAN, 'vulkanVersion'], text=True).strip()
- except subprocess.CalledProcessError:
- _logger.exception('Error checking for Vulkan version.')
-
- return ''
+from batocera_common.vulkan import (
+ get_default_gpu_name as get_default_gpu_name,
+ get_discrete_gpu_index as get_discrete_gpu_index,
+ get_discrete_gpu_name as get_discrete_gpu_name,
+ get_discrete_gpu_uuid as get_discrete_gpu_uuid,
+ get_version as get_version,
+ has_discrete_gpu as has_discrete_gpu,
+ is_available as is_available,
+)
diff --git a/package/batocera/core/batocera-configgen/configgen/pyproject.toml b/package/batocera/core/batocera-configgen/configgen/pyproject.toml
index 988f9217ad1..d6164b5d7bb 100644
--- a/package/batocera/core/batocera-configgen/configgen/pyproject.toml
+++ b/package/batocera/core/batocera-configgen/configgen/pyproject.toml
@@ -1,23 +1,21 @@
[project]
name = "batocera-configgen"
-version = '1.5'
+version = '44.0'
dependencies = [
- 'batocera-common',
- 'PyYAML',
+ 'batocera-launch',
'toml',
'evdev; sys_platform == "linux"',
'pyudev; sys_platform == "linux"',
'configobj',
'ffmpeg-python',
'pillow',
- 'ruamel.yaml',
'requests',
'qrcode',
'pysdl2',
]
-[project.scripts]
-emulatorlauncher = "configgen.emulatorlauncher:launch"
+[project.entry-points."batocera_launch.emulators"]
+configgen = 'configgen.launch:GeneratorEmulator'
[build-system]
requires = ["hatchling"]
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3588-sdio.yml b/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3588-sdio.yml
deleted file mode 120000
index 1f23ae2af29..00000000000
--- a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3588-sdio.yml
+++ /dev/null
@@ -1 +0,0 @@
-configgen-defaults-rk3588.yml
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-zen3.yml b/package/batocera/core/batocera-configgen/configs/configgen-defaults-zen3.yml
deleted file mode 120000
index 47f065cd1a8..00000000000
--- a/package/batocera/core/batocera-configgen/configs/configgen-defaults-zen3.yml
+++ /dev/null
@@ -1 +0,0 @@
-configgen-defaults-x86_64.yml
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/3ds.csv b/package/batocera/core/batocera-configgen/data/special/3ds.csv
deleted file mode 100755
index 884f47c2e74..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/3ds.csv
+++ /dev/null
@@ -1,4 +0,0 @@
-0357 - New Love Plus (Japan);90
-New_Love_Plus_JPN_3DS-Kirin;90
-0680 - New Love Plus+ (Japan);90
-New_Love_Plus_Plus_JPN_3DS-Kirin;90
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/atomiswave.csv b/package/batocera/core/batocera-configgen/data/special/atomiswave.csv
deleted file mode 100755
index d65c8e53183..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/atomiswave.csv
+++ /dev/null
@@ -1,25 +0,0 @@
-anmlbskta;270
-anmlbskt;270
-blokpong;270
-ikaruga;270
-illvelo;270
-karous;270
-mamonoro;270
-psyvar2;270
-quizqgd;270
-radirgy;270
-radirgyo;270
-shikgam2;270
-sl2007;270
-shorse;270
-starhrcl;270
-shors2k1;270
-shorsep;270
-starhrsp;270
-shorsepr;270
-starhrpr;270
-trghearto;270
-trgheart;270
-trizeal;270
-undefeat;270
-waidrive;270
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/dice.csv b/package/batocera/core/batocera-configgen/data/special/dice.csv
deleted file mode 100755
index daee1a9e091..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/dice.csv
+++ /dev/null
@@ -1,2 +0,0 @@
-breakout;270
-pinpong;270
diff --git a/package/batocera/core/batocera-configgen/data/special/fbneo.csv b/package/batocera/core/batocera-configgen/data/special/fbneo.csv
deleted file mode 100755
index faceeea0181..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/fbneo.csv
+++ /dev/null
@@ -1,2957 +0,0 @@
-1941;270
-1941j;270
-1941r1;270
-1941u;270
-1942;270
-1942a;270
-1942abl;270
-1942b;270
-1942h;270
-1942p;270
-1942w;270
-1943;270
-1943b;270
-1943bj;270
-1943j;270
-1943ja;270
-1943jah;270
-1943kai;270
-1943mii;270
-1943u;270
-1943ua;270
-1945kiii;270
-1945kiiin;270
-1945kiiio;270
-19in1;90
-19xx;270
-19xxa;270
-19xxar1;270
-19xxb;270
-19xxd;270
-19xxh;270
-19xxj;270
-19xxjr1;270
-19xxjr2;270
-19xxu;270
-20pacgal;90
-20pacgalr0;90
-20pacgalr1;90
-20pacgalr2;90
-20pacgalr3;90
-20pacgalr4;90
-25pacman;90
-25pacmano;90
-39in1;90
-48in1;90
-48in1a;90
-48in1b;90
-48in1c;90
-4in1;90
-4in1a;90
-4in1b;90
-5;270
-600;90
-60in1;90
-800fath;90
-800fatha;90
-8ball;90
-8ball1;90
-8ballact;270
-8ballact2;270
-8bpm;90
-99lstwar;90
-99lstwara;90
-99lstwarb;90
-99lstwark;90
-aafb;270
-aafbb;270
-aafbc;270
-aafbd2p;270
-abattle;90
-abattle2;90
-abscam;90
-aceattac;270
-aceattaca;270
-acombat;90
-acombat3;90
-acombat4;90
-acombato;90
-acrobatm;270
-ad2083;90
-aerfboo2;270
-aerfboot;270
-aerofgt;270
-aerofgtb;270
-aerofgtc;270
-aerofgts;270
-afighter;270
-afightera;270
-afighterb;270
-afighterc;270
-afighterd;270
-afightere;270
-afighterf;270
-afighterg;270
-afighterh;270
-afire;90
-agallet;270
-agalleta;270
-agalletah;270
-agalletaj;270
-agalletak;270
-agalletat;270
-agalletau;270
-agalleth;270
-agalletj;270
-agalletk;270
-agallett;270
-agalletu;270
-airass;270
-airattck;270
-airattcka;270
-airduel;270
-airdueljm72;270
-airduelm72;270
-airduelu;270
-airraid;270
-ajax;90
-ajaxj;90
-akkanvdr;270
-alcon;270
-alibaba;90
-alibabab;90
-alieninv;270
-alieninvp2;270
-alinvade;90
-alleymas;270
-alphaho;270
-alphahob;270
-alphamis;270
-alphaxz;90
-alpine;270
-alpinea;270
-altair;90
-amatelas;270
-amazon;270
-amazont;270
-amidar;90
-amidar1;90
-amidarb;90
-amidarc;90
-amidaro;90
-amidars;90
-amidaru;90
-amigo;90
-amigo2;90
-androidp;90
-androidpo;90
-andromed;270
-angelkds;90
-anmlbskt;270
-anmlbskta;270
-anteater;90
-anteaterg;90
-anteatergg;90
-anteateruk;90
-apb;270
-apb1;270
-apb2;270
-apb3;270
-apb4;270
-apb5;270
-apb6;270
-apbf;270
-apbg;270
-aponow;90
-arabian;270
-arabiana;270
-aracnis;90
-arbalest;270
-arcadian;270
-argus;270
-arian;270
-ark1ball;90
-arkangc;90
-arkangc2;90
-arkanoid;90
-arkanoidj;90
-arkanoidja;90
-arkanoidjb;90
-arkanoidjbl;90
-arkanoidjbl2;90
-arkanoidu;90
-arkanoiduo;90
-arkatayt;90
-arkatour;90
-arkbloc2;90
-arkbloc3;90
-arkblock;90
-arkgcbl;90
-arkgcbla;90
-arknoid2;270
-arknoid2b;270
-arknoid2j;270
-arknoid2u;270
-arktayt2;90
-armchmp2;270
-armchmp2o;270
-armchmp2o2;270
-armedf;270
-armedff;270
-armorcar;90
-armorcar2;90
-ashura;270
-ashuraj;270
-ashurau;270
-asideral;90
-aso;270
-assault;90
-assaultj;90
-assaultp;90
-asterion;90
-astinvad;270
-astrians;90
-astroamb;90
-astrob;270
-astrob1;270
-astrob2;270
-astrob2a;270
-astrob2b;270
-astrobg;270
-astrof;90
-astrof2;90
-astrof3;90
-astroff;90
-astroff2;90
-astrowar;90
-asuka;270
-asukaj;270
-asukaja;270
-asylum;270
-ataqandr;90
-atetrisc;270
-atetrisc2;270
-atlantis;90
-atlantis2;90
-atlantisb;270
-atombjt;270
-attckufo;270
-avefenix;90
-avefenixl;90
-avefenixrf;90
-avengers;90
-avengers2;90
-azurian;90
-bagman;270
-bagmanf;270
-bagmanj;270
-bagmanm2;90
-bagmanmc;90
-bagmans;270
-bagmans3;270
-bagmans4;270
-bagnard;270
-bagnarda;270
-bagnardi;90
-bagnardio;90
-bakubrkr;90
-ballbomb;270
-baluba;90
-bandido;270
-bandit;90
-baracuda;90
-barrier;270
-baryon;270
-baryona;270
-batman2;270
-batrider;270
-batriderc;270
-batriderhk;270
-batriderj;270
-batriderja;270
-batriderk;270
-batridert;270
-batrideru;270
-batsugun;270
-batsuguna;270
-batsugunb;270
-batsugunsp;270
-battlane;90
-battlane2;90
-battlane3;90
-battles;90
-battles2;90
-battlnts;90
-battlntsa;90
-battlntsj;90
-battroad;90
-bbakraid;270
-bbakraidc;270
-bbakraidj;270
-bbakraidja;270
-bcruzm12;90
-beaminv;270
-beastf;270
-beastfp;90
-beezer;90
-beezer1;90
-berenstn;90
-bermudat;270
-bermudata;270
-bermudatj;270
-bgaregga;270
-bgareggabl;270
-bgareggabla;270
-bgareggacn;270
-bgareggahk;270
-bgaregganv;270
-bgareggat;270
-bgareggat2;270
-bgareggatw;270
-bigbang;270
-bigevglf;270
-bigevglfj;270
-bigkong;270
-bigprowr;270
-bikkuric;90
-billiard;90
-bioatack;270
-birdiy;270
-birdtry;270
-birdtrya;270
-bjtwin;270
-bjtwina;270
-bjtwinp;270
-bjtwinpa;270
-bking;270
-bking2;90
-bking3;90
-blackhol;270
-bladestl;90
-bladestle;90
-bladestll;90
-blastoff;90
-blazer;90
-blkbustr;90
-blkhole;90
-block;270
-block2;90
-blockbl;270
-blockcar;90
-blockcarb;90
-blockch;270
-blockgal;90
-blockgalb;90
-blockj;270
-blockr1;270
-blockr2;270
-blokpong;270
-blswhstl;90
-blswhstla;90
-bluehawk;270
-bluehawkn;270
-blueprnt;270
-blueprntj;270
-bnj;270
-bnjm;270
-boggy84;90
-boggy84b;90
-boggy84b2;90
-bombbee;90
-bomber;90
-bombjack;90
-bombjack2;90
-bombjackt;90
-bombsa;270
-bongo;90
-boobhack;270
-boomrang;270
-boomranga;270
-boonggab;270
-botanic;90
-botanic2;90
-botanicf;270
-bowl3d;90
-bowler;90
-bowlrama;270
-brdrlinb;270
-brdrline;270
-brdrlinet;270
-brdrlins;270
-breakout;90
-brickzn;90
-brickzn11;90
-brickznv4;90
-brickznv5;90
-brix;90
-bronx;90
-brubber;270
-brvblade;270
-brvbladea;270
-brvbladej;270
-brvbladeu;270
-btime;270
-btime2;270
-btime3;270
-btimem;270
-btlfield;90
-btlfieldb;90
-bucaner;90
-bucanera;90
-buggychl;270
-buggychlt;270
-bullfgtr;90
-bullfgtrs;90
-bullsdrt;270
-bullsdrtg;90
-buraiken;90
-buraikenb;90
-buzzard;90
-bwings;90
-bwingsa;90
-bwingso;90
-cadanglr;270
-cairblad;270
-calibr50;270
-calipso;90
-cannballv;270
-cannonb;90
-cannonb2;90
-cannonb3;90
-cannonbp;90
-capbowl;270
-capbowl2;270
-capbowl3;270
-capbowl4;270
-capitol;90
-caractn;270
-caractn2;270
-carhntds;270
-carjmbre;90
-carnival;270
-carnivalb;270
-carnivalc;270
-carnivalca;270
-carnivalh;270
-carnivalha;270
-castfant;270
-catacomb;90
-catapult;270
-caterplr;270
-catnmous;90
-catnmousa;90
-cavelon;90
-cbdash;270
-cbnj;270
-cbtime;270
-cburnrub;270
-cburnrub2;270
-cburnrubj;270
-cchasm;270
-cchasm1;270
-cdiscon1;270
-cencourt;270
-centipdb;270
-centipdb2;270
-centipdd;270
-centiped;270
-centiped1;270
-centiped2;270
-centiped3;270
-centipedj;270
-cexplore;270
-cfarm;90
-cfboy0a1;270
-cfghtice;270
-cfishing;270
-cflyball;270
-cgraplop;270
-cgraplop2;270
-cgraplopj;270
-chamburger;270
-champbwl;270
-chanbara;270
-checkman;90
-checkmanj;90
-cheekyms;270
-chinhero;90
-chinhero2;90
-chinhero3;90
-chinherot;90
-chkun;90
-chopper;270
-choppera;270
-chopperb;270
-chqflag;90
-chqflagj;90
-chwy;270
-circusc;90
-circusc2;90
-circusc3;90
-circusc4;90
-circuscc;90
-circusce;90
-cityatta;90
-citybomb;270
-citybombj;270
-ckong;270
-ckongalc;270
-ckongcv;90
-ckongdks;270
-ckongg;90
-ckonggx;90
-ckongis;90
-ckongmc;90
-ckongmc2;90
-ckongo;270
-ckongpt2;270
-ckongpt2a;270
-ckongpt2b;270
-ckongpt2b2;270
-ckongpt2j;270
-ckongpt2jeu;270
-ckongpt2ss;270
-ckongs;90
-clapapa;270
-clapapa2;270
-clbowl;270
-clocknch;270
-clocknchj;270
-clubpacm;90
-clubpacma;90
-cmanhat;270
-cmissnx;270
-cmmb103;270
-cmmb162;270
-cnightst;270
-cnightst2;270
-cntsteer;270
-cocoloco;90
-cocolocoa;90
-cocolocob;90
-colony7;270
-colony7a;270
-combh;270
-commando;270
-commandob;270
-commandob2;270
-commandob3;270
-commandoj;270
-commandou;270
-commandou2;270
-commsega;90
-complexx;270
-condor;90
-condorn;90
-congo;90
-congoa;90
-conquer;90
-contra;90
-contra1;90
-contrab;90
-contrabj;90
-contrabj1;90
-contrae;90
-contraj;90
-contraj1;90
-cookrace;270
-coozumou;270
-cosmica;270
-cosmica1;270
-cosmica22;270
-cosmica23;270
-cosmica2a;270
-cosmicg;270
-cosmicgi;270
-cosmicin;270
-cosmicm2;270
-cosmicmo;270
-cosmo;90
-cosmogng;90
-cosmogngj;90
-cosmos;90
-cottong;90
-cowtipp;270
-cowtippa;270
-cppicf;270
-cppicf2;270
-cprobowl;270
-cprogolf;270
-cprogolf18;270
-cprogolfj;270
-cpsoccer;270
-cpsoccerj;270
-cptennis;270
-cptennisj;270
-crashrd;270
-crazyblk;90
-crazycop;90
-crbaloon;90
-crbaloon2;90
-crockman;90
-crockmnf;90
-crshrace;270
-crshrace2;270
-crush;90
-crush2;90
-crush3;90
-crush4;90
-crush5;90
-crushbl;90
-crushbl2;90
-crushbl3;90
-crushrlf;90
-crushs;90
-crzrally;270
-crzrallya;270
-crzrallyg;270
-crzrallyrf;270
-cscrtry;270
-cscrtry2;270
-csdtenis;270
-cshooter;270
-cshootert;270
-cskater;270
-csuperas;270
-csweetht;270
-ctainv;270
-cterrani;270
-ctisland;270
-ctisland2;270
-ctisland3;270
-ctornado;270
-ctrpllrp;90
-curvebal;270
-cutieq;90
-cybattlr;90
-cyclshtg;90
-cyvern;90
-cyvernj;90
-czeroize;270
-dai3wksi;270
-daikaiju;270
-daioh;270
-daioha;270
-daiohc;270
-daiohp;270
-daiohp2;270
-daisenpu;270
-dambustr;90
-dambustra;90
-dambustruk;90
-dangar;270
-dangara;270
-dangarb;270
-dangarbt;270
-dangarj;270
-dangseed;90
-darkmist;270
-darkwar;90
-darthvdr;270
-darwin;270
-dazzler;90
-dblewing;90
-dblewinga;90
-dblewingb;90
-ddaydoo;270
-ddayjlc;90
-ddayjlca;90
-ddonpach;270
-ddonpacha;270
-ddonpachj;270
-ddp2;270
-ddp2100;270
-ddp2100c;270
-ddp2100hk;270
-ddp2100j;270
-ddp2100k;270
-ddp2100t;270
-ddp2101;270
-ddp2101c;270
-ddp2101hk;270
-ddp2101j;270
-ddp2101k;270
-ddp2101t;270
-ddp2c;270
-ddp2hk;270
-ddp2j;270
-ddp2k;270
-ddp2t;270
-ddp3;270
-ddpdfk;270
-ddpdfk10;270
-ddpdoj;270
-ddpdoja;270
-ddpdojb;270
-ddpdojblk;270
-ddpdojblka;270
-ddpdojblkbl;270
-ddpdojp;270
-ddpdojt;270
-decomult;270
-demoneye;270
-desertbr;270
-desertbrd;270
-desertbrj;270
-desertbrjd;270
-desertwr;270
-desterth;270
-destryer;90
-destryera;90
-detatwin;90
-devilfsg;270
-devilfsh;90
-devstors;90
-devstors2;90
-devstorsv;90
-devstorsx;90
-devzone;270
-devzone2;270
-dfeveron;270
-dfkbl;270
-digdug;90
-digdug1;90
-digdug2;90
-digdug2o;90
-digdugat;90
-digdugat1;90
-digger;270
-diggerc;90
-digsid;90
-dimahoo;270
-dimahoou;270
-dimahoud;270
-dingo;90
-dingoe;90
-dirtfoxj;90
-disco;270
-discoboy;270
-discoboyp;270
-discof;270
-divebomb;270
-dking;270
-dkingjr;270
-dkmb;270
-dkong;270
-dkong3;270
-dkong3abl;90
-dkong3b;270
-dkong3hs;270
-dkong3j;270
-dkongddk;270
-dkongf;270
-dkonghrd;270
-dkonghs;270
-dkongike;270
-dkongj;270
-dkongjnrj;270
-dkongjo;270
-dkongjo1;270
-dkongjr;270
-dkongjr2;270
-dkongjrb;270
-dkongjrc;270
-dkongjre;270
-dkongjrhs;270
-dkongjrj;270
-dkongjrm;90
-dkongjrmc;90
-dkongjrpb;270
-dkongo;270
-dkongpe;270
-dkongx;270
-dkongx11;270
-docastle;270
-docastle2;270
-docastleo;270
-dockman;90
-dockmanb;90
-dockmanc;90
-dogfight;270
-dogfightp;270
-dogosokb;270
-dogosoke;270
-dogyuun;270
-dogyuuna;270
-dogyuunt;270
-dommy;270
-donpachi;270
-donpachihk;270
-donpachij;270
-donpachijs;270
-donpachikr;270
-dorachan;270
-dorodon;270
-dorodon2;270
-douni;270
-downtown;270
-downtown2;270
-downtownj;270
-downtownp;270
-draco;90
-dragnblz;270
-drakton;270
-dremshpr;270
-driftout;270
-driftoutct;270
-driftoutj;270
-driveout;270
-drivfrcb;90
-drivfrcg;90
-drivfrcp;90
-drivfrcsg;90
-drivfrcsga;90
-drivfrct;90
-drktnjr;270
-drmicro;270
-drtoppel;90
-drtoppelj;90
-drtoppelu;90
-dsaber;90
-dsabera;90
-dsaberj;90
-dspirit;90
-dspirit1;90
-dspirit2;90
-dualaslt;270
-dyger;270
-dygera;270
-dynamski;90
-dzigzag;90
-eagle;90
-eagle2;90
-eagle3;90
-earthjkr;270
-earthjkra;270
-earthjkrb;270
-earthjkrp;270
-eeekk;270
-eeekkp;90
-efdt;90
-eggor;90
-eggs;270
-eightfrc;90
-elecyoyo;270
-elecyoyo2;270
-enigma2;270
-enigma2a;270
-enigma2b;270
-equites;90
-equitess;90
-escmars;270
-espgal;270
-espgal2;270
-espgal2a;270
-espgalbl;270
-esprade;270
-espradej;270
-espradejo;270
-evilston;270
-excthour;270
-exctscc2;270
-exctsccr;270
-exctsccra;270
-exctsccrb;270
-exctsccrj;270
-exctsccrjo;270
-exctsccru;270
-exedexes;270
-exerion;90
-exerionb;90
-exerionb2;90
-exerionba;90
-exeriont;90
-exerizer;90
-exerizerb;90
-exodus;90
-explbrkr;90
-explbrkrk;90
-explorer;90
-extrmatn;270
-extrmatnj;270
-extrmatnu;270
-extrmatnur;270
-eyes;90
-eyes2;90
-eyesb;90
-eyeszac;90
-eyeszacb;90
-f1gp;90
-f1gp2;90
-f1gpa;90
-f1gpb;90
-fa;90
-falcon;90
-falconz;90
-fantasia;90
-fantasiaa;90
-fantasiab;90
-fantasian;90
-fantastc;90
-fantasyg;90
-fantasyg2;90
-fantasyj;90
-fantasyu;90
-fantazia;90
-fantsy95;90
-fastdraw;90
-fastdraw_130;90
-fastfred;90
-fastlane;90
-fcombat;90
-fenix;90
-fenixn;90
-feversos;270
-fghtatck;90
-fgoal;90
-fgoala;90
-fhawk;270
-fhawkj;270
-fi6845;270
-fi8275;270
-fightrol;270
-finalizr;90
-finalizra;90
-finalizrb;90
-firebarr;270
-firebatl;90
-fireshrk;270
-fireshrka;270
-fireshrkd;270
-fireshrkdh;270
-firetrap;90
-firetrapa;90
-firetrapbl;90
-firetrapj;90
-firetrk;270
-fitter;90
-fitterbl;90
-fiveside;90
-fixeight;270
-fixeighta;270
-fixeightat;270
-fixeightbl;270
-fixeighth;270
-fixeightht;270
-fixeightj;270
-fixeightjt;270
-fixeightk;270
-fixeightkt;270
-fixeightt;270
-fixeighttw;270
-fixeighttwt;270
-fixeightu;270
-fixeightut;270
-flipjack;270
-flkatck;90
-flkatcka;90
-floritas;90
-floritasm;90
-flyboy;90
-flyboyb;90
-flytiger;270
-flytigera;270
-fnkyfish;90
-fnshark;270
-fourplay;90
-freekick;270
-freekicka;270
-freekickb1;270
-freekickb2;270
-freekickb3;270
-freeze;90
-frogf;90
-frogg;90
-frogger;90
-froggeram;90
-froggermc;90
-froggers;90
-froggers1;90
-froggers2;90
-froggers3;90
-froggerv;90
-froggervd;90
-froggrs;90
-frontlin;270
-frontlina;270
-fshark;270
-fsharkb;270
-fsharkbt;270
-fspiderb;90
-fstarfrc;90
-fstarfrcj;90
-fstarfrcw;90
-funkybee;90
-funkybeeb;90
-funnymou;90
-futari10;270
-futari15;270
-futari15a;270
-futaribl;270
-futariblj;270
-futflash;270
-futspy;90
-fx;90
-gaiapols;90
-gaiapolsj;90
-gaiapolsu;90
-galactic;270
-galaga;90
-galaga3;90
-galaga3a;90
-galaga3b;90
-galaga3c;90
-galaga3m;90
-galaga88;270
-galaga88a;90
-galaga88j;90
-galagamf;90
-galagamk;90
-galagamw;90
-galagao;90
-galaktron;90
-galap1;90
-galap4;90
-galapx;90
-galaxbsf;90
-galaxbsf2;90
-galaxcirsa;90
-galaxia;90
-galaxiaa;90
-galaxiab;90
-galaxiac;90
-galaxian;90
-galaxiana;90
-galaxianbl;90
-galaxianbl2;90
-galaxianbl3;90
-galaxianem;90
-galaxiani;90
-galaxianm;90
-galaxianmo;90
-galaxianrp;90
-galaxiant;90
-galaxrcgg;90
-galaxrf;90
-galaxrfgg;90
-galaxygn;90
-galaxygnh;90
-galaxyx;90
-galemp;90
-galgame;270
-galivan;270
-galivan2;270
-galivan3;270
-galkamika;90
-gallag;90
-galmedes;270
-galmonst;270
-galpani2;90
-galpani2e;90
-galpani2e2;90
-galpani2g;90
-galpani2gs;90
-galpani2i;90
-galpani2i2;90
-galpani2j;90
-galpani2t;90
-galpani3;90
-galpani3hk;90
-galpani3j;90
-galpani3k;90
-galpanic;90
-galpanica;90
-galpanicb;90
-galpanicms;90
-galsnew;90
-galsnewj;90
-galsnewk;90
-galsnewt;90
-galsnewu;90
-galspnbl;90
-galturbo;90
-galxwars;270
-galxwars2;270
-galxwarst;270
-galxwarst2;270
-gametngk;270
-gangonta;90
-gaplus;90
-gaplusa;90
-gaplusd;90
-gaplust;90
-gardia;270
-gardiab;270
-gardiaj;270
-garuka;90
-gatsbee;90
-gbusters;90
-gbustersa;90
-gcpinbal;270
-geebee;90
-geebeea;90
-geebeeb;90
-geebeeg;90
-gekiridn;270
-gekiridnj;270
-gekisou;90
-gemcrush;270
-gemini;90
-geminib;90
-geminij;90
-getaway;270
-gground;270
-ggroundj;270
-ghostmun;90
-ghox;270
-ghoxj;270
-ghoxjo;270
-gigas;270
-gigasb;270
-gigasm2;270
-gigasm2b;270
-gmahou;270
-gmgalax;90
-gnbarich;270
-goindol;90
-goindolj;90
-goindolu;90
-goldbug;90
-gondo;270
-gondou;270
-gorf;270
-gorfpgm1;270
-gorfpgm1g;270
-gorkans;90
-gotya;270
-gp2quiz;90
-gp2se;90
-grasspin;270
-grchamp;270
-grchampa;270
-grchampb;270
-grdnstrmg;270
-grdnstrmj;270
-grdnstrmk;270
-grdnstrmv;270
-greenber;270
-grescue;270
-griffon;90
-griffono;90
-grindstm;270
-grindstma;270
-grobda;90
-grobda2;90
-grobda3;90
-gryzor;90
-gryzor1;90
-gseeker;90
-gseekerj;90
-gseekeru;90
-gstream;270
-gteikoku;90
-gteikokub;270
-gteikokub2;90
-gteikokub3;90
-gulfstrm;270
-gulfstrma;270
-gulfstrmb;270
-gulfstrmk;270
-gulfstrmm;270
-gulfwar2;270
-gulfwar2a;270
-gunball;270
-gunbird;270
-gunbird2;270
-gunbird2a;270
-gunbirdj;270
-gunbirdk;270
-gundealr;270
-gundealra;270
-gundealrbl;270
-gundealrt;270
-gundhara;270
-gundharac;270
-gunfront;270
-gunfrontj;270
-gunlock;90
-gunlocko;270
-gunnail;270
-gunnailb;270
-gunnailp;270
-gunsmoke;270
-gunsmokeb;270
-gunsmokeg;270
-gunsmokej;270
-gunsmokeu;270
-gunsmokeua;270
-gunsmokeub;270
-gutangtn;90
-guttangt;90
-guttangts3;90
-guwange;270
-guwanges;270
-guzzler;90
-guzzlers;90
-gwar;270
-gwara;270
-gwarab;270
-gwarb;270
-gwarj;270
-gyrodine;90
-gyrodinet;90
-gyruss;90
-gyrussb;90
-gyrussce;90
-hal21;270
-hal21j;270
-halleysc;90
-halleysc87;90
-halleyscj;90
-halleyscja;90
-halleyscjp;90
-hamaway;90
-hangly;90
-hangly2;90
-hangly3;90
-harem;90
-hbarrel;270
-hbarrelu;270
-headoni;270
-headonn;270
-heartatk;90
-heiankyo;270
-helifire;270
-helifirea;270
-herbiedk;270
-hero;90
-herodk;270
-herodku;270
-hexpool;90
-hexpoola;90
-highsplt;270
-highsplta;270
-highspltb;270
-hishouza;270
-hishouzab;270
-hncholms;90
-hoccer;90
-hoccer2;90
-homo;90
-hopprobo;90
-horekid;270
-horekidb;270
-horshoes;270
-hotdogst;90
-hotnight;90
-hotpinbl;90
-hotshock;90
-hotshockb;90
-hotsmash;90
-hstennis;90
-hstennis10;90
-hunchbak;90
-hunchbaka;90
-hunchbgb;90
-hunchbkd;270
-hunchbkg;90
-hunchbks;90
-hunchbks2;90
-huncholy;90
-hustler;90
-hustlerb;90
-hustlerb2;90
-hustlerb3;90
-hustlerb4;90
-hustlerb5;90
-hustlerb6;90
-hustlerd;90
-hwrace;270
-ibara;270
-ibarablk;270
-ibarablka;270
-igmo;270
-ikari;270
-ikaria;270
-ikaria2;270
-ikarijp;270
-ikarijpb;270
-ikarinc;270
-ikariram;270
-ikaruga;270
-illvelo;270
-imago;90
-imagoa;90
-imgfight;270
-imgfightb;270
-imgfightj;270
-imolagp;90
-imolagpo;90
-indianbt;270
-indianbtbr;270
-indy800;90
-intrepid;90
-intrepid2;90
-intrepidb;90
-intrepidb2;90
-intrepidb3;90
-intruder;270
-invad2ct;90
-invaddlx;270
-invader4;270
-invaderl;270
-invadernc;270
-invaders;270
-invadersem;270
-invadpt2;270
-invadpt2a;270
-invadpt2br;270
-invadrmr;270
-invasion;270
-invasiona;270
-invasiona2;270
-invasionb;270
-invasionrz;270
-invasionrza;270
-invcarht;270
-invds;270
-invho2;270
-invho2a;270
-invinco;270
-invmulti;270
-invmultim1a;270
-invmultim2a;270
-invmultim2c;270
-invmultim3a;270
-invmultip;270
-invmultis1a;270
-invmultis2a;270
-invmultis3a;270
-invmultit3d;270
-invqix;270
-invrvnge;270
-invrvngea;270
-invrvngeb;270
-invrvngedu;270
-invrvngegw;270
-ipminvad;270
-ipminvad1;270
-irion;90
-ixion;270
-jack;90
-jack2;90
-jack3;90
-jackal;90
-jackalbl;90
-jackalj;90
-jackalr;90
-jackler;90
-jackrabt;90
-jackrabt2;90
-jackrabts;90
-jcross;270
-jcrossa;270
-jin;90
-jjack;270
-joinem;90
-jollyjgr;90
-jongbou;90
-josvolly;90
-journey;90
-joust2;270
-joust2r1;270
-joyfulr;270
-joyman;90
-jrking;270
-jrpacman;90
-jrpacmanf;90
-jrpacmbl;90
-jspecter;270
-jspecter2;270
-jumpbug;90
-jumpbugb;90
-jumpcoas;90
-jumpcoasa;90
-jumpcoast;90
-jumpkun;90
-jumpshot;90
-jumpshotp;90
-jungler;90
-junglero;90
-junglers;90
-jungsub;90
-junofrst;90
-junofrstg;90
-kageki;90
-kagekih;90
-kagekij;90
-kagekiu;90
-kaitei;90
-kaitein;90
-kamakazi3;90
-kamikaze;270
-kamikazesp;90
-kamikazp;90
-kamikcab;270
-kangaroo;90
-kangarooa;90
-kangaroob;90
-kangarool;90
-kaos;270
-karateda;90
-karatedo;90
-karatevs;90
-karous;270
-kbh;270
-kbm;270
-kbm2nd;270
-kbm3rd;270
-kchamp;90
-kchamptec;90
-kchampvs;90
-kchampvs2;90
-kchampvs3;90
-kchampvs4;90
-ket;270
-ket1;270
-keta;270
-ketarr;270
-ketarr10;270
-ketarr15;270
-ketarr151;270
-ketarrf;270
-ketarrs15;270
-ketarrs151;270
-ketb;270
-ketbl;270
-kick;90
-kickc;90
-kicker;90
-kickman;90
-kikikai;90
-kingball;90
-kingballj;90
-kingdmgp;270
-kingofb;90
-kingofbj;90
-knightb;90
-knockout;90
-knockoutb;90
-knockoutc;90
-komemokos;270
-konam80a;90
-konam80j;90
-konam80k;90
-konam80s;90
-konam80u;90
-kong;90
-korosuke;90
-kosmokil;270
-krull;270
-krzybowl;270
-ktiger;270
-ktiger2;270
-kuhga;270
-kyros;90
-kyrosj;90
-labyrunr;90
-labyrunrk;90
-ladybug;270
-ladybugb;270
-ladybugb2;270
-ladybugg;270
-ladyfrog;90
-ladykill;90
-ladymstr;90
-ladymstr2;90
-lagirl;270
-lagunar;90
-laser;270
-laserbas;270
-laserbasa;270
-lasso;90
-lastday;270
-lastdaya;270
-lastduel;270
-lastduelb;270
-lastduelj;270
-lastduelo;270
-lastmisn;270
-lastmisnj;270
-lastmisnu5;270
-lastmisnu6;270
-leds2011;270
-leds2011p;270
-leds2011u;270
-ledstorm;270
-legion;270
-legionj;270
-legionjb;270
-legionjb2;270
-legofair;270
-lethalth;270
-levers;90
-lgtnfght;90
-lgtnfghta;90
-lgtnfghtu;90
-liberate;270
-liberateb;270
-lizwiz;90
-lnc;270
-locoboot;90
-locomotn;90
-logger;90
-loggerr2;90
-looping;90
-loopingv;90
-loopingva;90
-losttomb;90
-losttombh;90
-loverboy;90
-lrescue;270
-lrescuem;270
-lrescuem2;270
-lsasquad;270
-lupin3;270
-lupin3a;270
-lwings;90
-lwings2;90
-lwingsb;90
-lwingsj;90
-lwingsja;90
-m660;90
-m660b;90
-m660j;90
-mach9;270
-machomou;90
-macross;270
-macrossp;270
-madalien;270
-madaliena;270
-madalienb;270
-madgear;270
-madgearj;270
-madshark;270
-mag_day;90
-mag_time;90
-mag_war;90
-mag_wara;90
-magspot;270
-magspot2;270
-maguila;270
-magworm;270
-magworma;270
-mahoudai;270
-majest12j;270
-majest12u;270
-maketrax;270
-maketrxb;270
-makyosen;270
-mamonoro;270
-mandinga;90
-mandingac;90
-mandingaeg;90
-mandingarf;90
-maniach;270
-maniach2;270
-manybloc;270
-mappy;90
-mappyj;90
-marinedt;270
-mariner;90
-mars;90
-marsarf;90
-marvins;270
-masterw;270
-masterwj;270
-masterwu;270
-matmania;270
-mazeinv;270
-mazinger;90
-mazingerj;90
-mbrush;90
-megadon;270
-megaforc;90
-megaforcu;90
-megazone;90
-megazonea;90
-megazoneb;90
-megazoneh;90
-megazonei;90
-megazonej;90
-mercenario;270
-mercs;270
-mercsj;270
-mercsu;270
-mercsur1;270
-merlinmm;90
-metafox;270
-meteor;270
-meteora;90
-metlfrzr;270
-metlhawk;90
-metlhawkj;90
-mgolf;270
-mightguy;270
-mikie;270
-mikiehs;270
-mikiej;270
-mikiek;270
-millipdd;270
-milliped;270
-millpac;270
-mimonkey;90
-mimonkeyug;90
-mimonsco;90
-mimonscr;90
-mimonscra;90
-minefld;90
-minefldfe;90
-mirax;90
-miraxa;90
-misncrft;90
-misncrfta;90
-mjleague;270
-mlander;270
-mltiwars;90
-mmagic;270
-mmonkey;270
-mmonkeyj;270
-mmpork;270
-mnchmobl;270
-mocapb;90
-mocapbj;90
-mocapglf;90
-moegonta;90
-mofflott;270
-moguchan;270
-monkeyd;270
-monsterb;270
-monsterb2;270
-montecar;270
-monymony;90
-monymony2;90
-monzagp;270
-monzagpb;270
-moonal2;90
-moonal2b;90
-moonaln;90
-moonbase;270
-moonbasea;270
-mooncmw;90
-mooncptc;90
-mooncreg;90
-mooncrgx;270
-mooncrs2;90
-mooncrs3;90
-mooncrs4;90
-mooncrs5;90
-mooncrsb;90
-mooncrsl;90
-mooncrst;90
-mooncrstg;90
-mooncrsto;90
-mooncrstso;90
-mooncrstu;90
-mooncrstuk;90
-mooncrstuku;90
-mooncrstuu;90
-moonqsr;90
-moonwar;90
-moonwara;90
-motorace;270
-motos;90
-mototour;270
-mouncrst;90
-mouser;90
-mouserc;90
-mplanets;270
-mplanetsuk;270
-mrdo;270
-mrdobl;270
-mrdofabr;270
-mrdofix;270
-mrdot;270
-mrdoy;270
-mrdu;270
-mrflea;270
-mrjong;90
-mrkougar;90
-mrkougar2;90
-mrkougb;90
-mrkougb2;90
-mrlo;270
-mrsdyna;270
-mrtnt;90
-mrviking;270
-mrvikingj;270
-mschamp;90
-mschamps;90
-msheartb;90
-msisaac;270
-mspacii;90
-mspacii2;90
-mspacmab;90
-mspacmab2;90
-mspacmab3;90
-mspacmab4;90
-mspacman;90
-mspacmanbcc;90
-mspacmanbco;90
-mspacmanbg;90
-mspacmanbg2;90
-mspacmanbgd;90
-mspacmanbhe;90
-mspacmanbi;90
-mspacmanblt;90
-mspacmanblt2;90
-mspacmancr;90
-mspacmat;90
-mspacmbe;90
-mspacmbmc;90
-mspacmbn;90
-mspacmnf;90
-mspacpls;90
-mspactwin;90
-mspuzzle;90
-mspuzzlea;90
-mspuzzleb;90
-multiped;270
-mushisam;270
-mushisama;270
-mushisamb;270
-musicbal;270
-mustache;90
-mustachei;90
-mx5000;90
-myqbert;270
-mystston;270
-myststono;270
-myststonoi;270
-naughtyb;90
-naughtyba;90
-naughtybb;90
-naughtybc;90
-navarone;90
-ncentury;90
-ncv1;90
-ncv1j;90
-ncv1j2;90
-ncv2;90
-ncv2j;90
-nebulbee;90
-nebulray;90
-nebulrayj;90
-neobattl;270
-netwars;90
-newfant;90
-newfanta;90
-newpuc2;90
-newpuc2b;90
-newpuckx;90
-newsin7;90
-newsin7a;90
-nextfase;90
-nibbler;90
-nibbler6;90
-nibbler7;90
-nibbler8;90
-nibblera;90
-nibblero;90
-nibblerp;90
-nightmare;90
-ninjemak;270
-nitrobal;270
-nitrobala;270
-nitrobalb;270
-nmouse;90
-nmouseb;90
-noahsark;90
-nob;270
-nobb;270
-nomnlnd;270
-nomnlndg;270
-nost;270
-nostj;270
-nostk;270
-nsub;270
-numcrash;90
-nunchaku;90
-nyny;270
-nynyg;270
-offensiv;90
-ohpaipee;270
-oigas;270
-olibochu;270
-olibug;90
-olmandingc;90
-olmandingo;90
-omega;270
-omegaa;270
-omegab;270
-omegaf;270
-omegafs;270
-omni;90
-ooparts;270
-orbite;270
-orbitron;270
-outline;90
-outzone;270
-outzonea;270
-outzoneb;270
-outzonec;270
-outzonecv;270
-outzoneh;270
-overdriv;90
-overdriva;90
-overdrivb;90
-ozmawars;270
-ozmawars2;270
-ozmawarsmr;270
-ozon1;90
-p911;90
-p9112;90
-p911ac;90
-p911ea;90
-p911ed;90
-p911j;90
-p911k;90
-p911kc;90
-p911ud;90
-pac90;90
-pacgal;90
-pacgal2;90
-pacheart;90
-pachifev;270
-packbang;90
-pacman;90
-pacmanbl;270
-pacmanbla;270
-pacmanblb;90
-pacmanblc;270
-pacmanblci;270
-pacmanblv;270
-pacmanf;90
-pacmanfm;90
-pacmania;270
-pacmaniaj;90
-pacmaniao;270
-pacmanjpm;90
-pacmanpe;90
-pacmanso;90
-pacmansp;90
-pacmanug;90
-pacmanvg;90
-pacmod;90
-pacnchmp;90
-pacnpal;90
-pacnpal2;90
-pacominv;270
-pacominva;270
-pacplus;90
-pacuman;90
-paddle2;90
-paddlema;90
-painter;90
-paintrlr;90
-pairlove;270
-pajaroes;90
-pandoras;90
-panic;270
-panic2;270
-panic3;270
-panicger;270
-panich;270
-panicr;270
-panicrg;270
-panther;270
-paprazzi;270
-para2dx;90
-paradise;90
-paradisea;90
-paradisee;90
-paradlx;90
-passht4b;270
-passsht;270
-passsht16a;270
-passshta;270
-passshtad;270
-passshtb;270
-passshtd;270
-passshtj;270
-passshtjd;270
-pbaction;90
-pbaction2;90
-pbaction3;90
-pbaction4;90
-pbactiont;90
-pballoon;90
-pballoonr;90
-peggle;90
-pegglet;90
-pengo;90
-pengo2;90
-pengo2u;90
-pengo3u;90
-pengo4;90
-pengo5;90
-pengo6;90
-pengob;90
-pengojpm;90
-pengomc1;90
-pengomc2;90
-pengopac;90
-penta;90
-percuss;270
-perestro;90
-perestrof;90
-perfrman;270
-perfrmanu;270
-phelios;90
-pheliosj;90
-phoenix;90
-phoenix2;90
-phoenix3;90
-phoenixa;90
-phoenixass;90
-phoenixb;90
-phoenixc;90
-phoenixc2;90
-phoenixc3;90
-phoenixc4;90
-phoenixdal;90
-phoenixgu;90
-phoenixha;90
-phoenixi;90
-phoenixj;90
-phoenixs;90
-phoenixt;90
-phoenxp2;270
-phozon;90
-phozons;90
-pickin;270
-pignewt;270
-pignewta;270
-pinbo;90
-pinboa;90
-pinbos;90
-pinguinos;90
-pinkswts;270
-pinkswtsa;270
-pinkswtsb;270
-pinkswtssc;270
-pinkswtsx;270
-piranha;90
-piranhah;90
-piranhao;90
-piratesh;90
-pisces;90
-piscesb;90
-pitnrun;90
-pitnruna;90
-playball;270
-pleiadbl;90
-pleiadce;90
-pleiads;90
-pleiadsb2;90
-pleiadsi;90
-pleiadsn;90
-pleiadss;90
-plgirls;270
-plgirls2;270
-plgirls2b;270
-plusalph;270
-plygonet;90
-pntnpuzl;90
-polaris;270
-polarisa;270
-polarisb;270
-polarisbr;270
-polariso;270
-pollux;270
-polluxa;270
-polluxa2;270
-polluxn;270
-polynetw;90
-pootan;90
-pooyan;90
-pooyans;90
-popeyeman;90
-popflame;90
-popflamea;90
-popflameb;90
-popflamen;90
-popper;90
-porky;90
-porter;90
-portera;90
-portman;90
-portmanj;90
-portrait;270
-portraita;270
-poundfor;270
-poundforj;270
-poundforu;270
-ppking;90
-pprobe;90
-progolf;270
-progolfa;270
-progress;90
-prosoccr;270
-prosport;270
-prosporta;270
-protenn;270
-protennb;270
-prtytime;90
-psurge;270
-psychic5;270
-psychic5j;270
-psyvar2;270
-psyvaria;270
-psyvarij;270
-psyvarrv;270
-pturn;90
-puckman;90
-puckmanb;90
-puckmanf;90
-puckmanh;90
-puckmod;90
-pulsar;270
-puzlclub;90
-pwrflip;270
-pzlestar;270
-qbert;270
-qberta;270
-qbertj;270
-qbertqub;270
-qberttst;270
-qix;270
-qix2;270
-qixa;270
-qixb;270
-qixo;270
-quaak;90
-quantum;270
-quantum1;270
-quantump;270
-quarterb;270
-quarterba;270
-quarterbc;270
-quasar;90
-quasara;90
-quester;90
-questers;90
-qwak;270
-r2dtank;270
-r2dx_v33;270
-r2dx_v33_r2;270
-raaspec;270
-rackemup;90
-radarscp;270
-radarscp1;270
-radarscpc;270
-radarzon;90
-radarzon1;90
-radarzont;90
-radirgy;270
-radirgyo;270
-raflesia;270
-raflesiau;270
-raiden;270
-raiden2;270
-raiden2dx;270
-raiden2e;270
-raiden2ea;270
-raiden2eg;270
-raiden2es;270
-raiden2eu;270
-raiden2eua;270
-raiden2eub;270
-raiden2eup;270
-raiden2f;270
-raiden2g;270
-raiden2hk;270
-raiden2i;270
-raiden2j;270
-raiden2k;270
-raiden2nl;270
-raiden2sw;270
-raiden2u;270
-raidena;270
-raidenb;270
-raidendx;270
-raidendxa1;270
-raidendxa2;270
-raidendxch;270
-raidendxg;270
-raidendxj;270
-raidendxja;270
-raidendxk;270
-raidendxnl;270
-raidendxpt;270
-raidendxu;270
-raidenk;270
-raidenkb;270
-raidenm;270
-raident;270
-raidenu;270
-raidenua;270
-raidenub;270
-raiders;90
-raidersr3;90
-rallybik;270
-raphero;270
-rapheroa;270
-rayforce;90
-rayforcej;90
-razmataz;90
-rcorsair;90
-rdft;270
-rdft2;270
-rdft22kc;270
-rdft2a;270
-rdft2aa;270
-rdft2it;270
-rdft2j;270
-rdft2ja;270
-rdft2jb;270
-rdft2jc;270
-rdft2s;270
-rdft2t;270
-rdft2u;270
-rdft2us;270
-rdfta;270
-rdftadi;270
-rdftam;270
-rdftau;270
-rdftauge;270
-rdftgb;270
-rdftgr;270
-rdftit;270
-rdftj;270
-rdftja;270
-rdftjb;270
-rdfts;270
-rdftu;270
-rdftua;270
-reaktor;90
-redalert;270
-redclash;270
-redclashs;270
-redclasht;270
-redclashta;270
-redfoxwp2;270
-redfoxwp2a;270
-redhawk;270
-redhawke;270
-redhawkk;270
-redlin2p;270
-redrobin;90
-redufo;270
-redufob;90
-redufob2;90
-regulus;270
-reguluso;270
-regulusu;270
-repulse;90
-resclunar;270
-rescue;90
-rescueb;90
-rescuefe;90
-retofinv;90
-retofinvb;90
-retofinvb1;90
-retofinvb2;90
-retofinvb3;90
-revenger;270
-revngr84;270
-rfjet;270
-rfjet2kc;270
-rfjeta;270
-rfjetj;270
-rfjets;270
-rfjetsa;270
-rfjett;270
-rfjetu;270
-ridleofp;90
-ringfgt;90
-ringfgt2;90
-ringking;90
-ringking2;90
-ringking3;90
-ringkingw;90
-roadf;90
-roadf2;90
-roadfh;90
-roadfu;90
-rockduck;270
-rockn;270
-rockn2;270
-rockn3;270
-rockn4;270
-rockna;270
-rocknms;270
-rocnrope;270
-rocnropek;270
-rollace;270
-rollace2;90
-rollingc;270
-rompers;90
-romperso;90
-ropeman;270
-rotaryf;270
-roundup;90
-route16;270
-route16a;270
-route16b;270
-route16bl;270
-route16c;270
-route16d;270
-routex;270
-routexa;270
-rshark;270
-rugrats;90
-rumba;270
-runaways;270
-rundeep;270
-rushcrsh;270
-ryujin;270
-ryujina;270
-s1945;270
-s1945a;270
-s1945bl;270
-s1945ii;270
-s1945iii;270
-s1945j;270
-s1945k;270
-s1945n;270
-s1945nj;270
-safarir;90
-safarirj;90
-samesame;270
-samesame2;270
-samesamecn;270
-samesamenh;270
-samurai;270
-samuraia;270
-sandscrp;90
-sandscrpa;90
-sandscrpb;90
-sasuke;90
-satansat;90
-satansata;90
-satansatind;90
-saturnzi;270
-savanna;90
-savgbees;270
-sbagman;270
-sbagman2;270
-sbagmani;90
-sbagmans;270
-sbasketb;90
-sbaskete;90
-sbasketg;90
-sbasketh;90
-sbdk;270
-sbomber;270
-sbombera;270
-sbowling;90
-sbrkout;270
-sbrkout3;270
-sbrkoutc;270
-sbrkoutct;270
-sbugger;270
-sbuggera;270
-schaser;270
-schasera;270
-schaserb;270
-schaserc;270
-schasercv;270
-schaserm;270
-schick;90
-scobra;90
-scobrab;90
-scobrae;90
-scobrae2;90
-scobrag;90
-scobraggi;90
-scobras;90
-scobrase;90
-scontra;90
-scontraa;90
-scontraj;90
-scorpion;90
-scorpiona;90
-scorpionb;90
-scorpionmc;90
-scotrsht;90
-scramb2;90
-scramb3;90
-scramblb;90
-scramble;90
-scramblebb;90
-scramblebf;90
-scramblebun;90
-scrambleo;90
-scrambler;90
-scrambles;90
-scrambles2;90
-scrambp;90
-scramce;90
-scrammr;90
-scrampt;90
-scramrf;90
-scregg;270
-scyclone;270
-sdtennis;270
-sdungeon;270
-sdungeona;270
-searchar;90
-searcharj;90
-searcharu;90
-searthie;270
-searthin;270
-searthina;270
-sectrzon;90
-sectrzona;90
-sectrzont;90
-seicross;90
-seicrossa;90
-sengekis;90
-sengekisj;90
-senjyo;90
-sexyboom;270
-sfkick;90
-sfkicka;90
-sflush;270
-sformula;90
-sformulaa;90
-shaolinb;90
-shaolins;90
-sheriff;270
-shienryu;270
-shikgam2;270
-shikigam;270
-shikigama;270
-shippumd;270
-shollow;90
-shollow2;90
-shootbul;90
-shtrider;270
-shtridera;270
-shtriderb;270
-shuffle;90
-shuttlei;270
-sia2650;270
-sianniv;270
-sicv;270
-sicv1;270
-silvmil;270
-sindbadm;270
-sinistar;270
-sinistar2;270
-sinistarp;270
-sinvasn;270
-sinvasnb;270
-sinvemag;270
-sinvzen;270
-sirio2;90
-sisv;270
-sisv1;270
-sisv2;270
-sisv3;270
-sitv;270
-sitv1;270
-skullfng;270
-skullfnga;270
-skullfngj;270
-skyadvnt;90
-skyadvntj;90
-skyadvntu;90
-skyalert;270
-skyarmy;90
-skybase;90
-skybump;90
-skychut;270
-skyfox;90
-skylancr;90
-skylancre;90
-skylove;270
-skyraidr;90
-skyshark;270
-skysharka;270
-skysharkb;270
-skysmash;270
-skysoldr;90
-skysoldrbl;90
-sl2007;270
-slapfigh;270
-slapfigha;270
-slapfighb1;270
-slapfighb2;270
-slapfighb3;270
-slither;270
-slithera;270
-slspirit;270
-smooncrs;90
-sncwgltd;270
-sngkace;270
-sngkacea;270
-solarfox;270
-solarwar;270
-solfight;270
-sonicbom;270
-sonicbomd;270
-sonicwi;270
-sonofphx;90
-sos;90
-spacbat2;90
-spacbatt;90
-spacbeam;270
-spaceat2;270
-spaceatt;270
-spaceatt2k;270
-spaceattbp;270
-spacebrd;270
-spacecho;270
-spacecho2;270
-spacecom;270
-spacecr;90
-spacedem;270
-spacefb;270
-spacefba;270
-spacefbb;270
-spacefbe;270
-spacefbe2;270
-spacefbg;270
-spacefev;270
-spacefevo;270
-spacefevo2;270
-spacefrt;90
-spaceg;270
-spaceint;90
-spaceintj;90
-spacelnc;270
-spacempr;90
-spaceod;270
-spaceod2;270
-spaceph;270
-spaceplt;90
-spaceplta;90
-spacerng;90
-spacetrk;270
-spacetrkc;270
-spacewr3;270
-spacmiss;270
-spactrai;90
-spbactn;90
-spbactnj;90
-spbactnp;90
-spcdrag;90
-spceking;270
-spcewarl;270
-spcewarla;270
-spcewars;270
-spcforc2;270
-spcforce;270
-spcfrcii;270
-spcinv95;270
-spcinv95u;270
-spcking2;270
-spclaser;270
-spclforc;270
-spcmission;90
-spcpostn;90
-spctbird;90
-spctrek;90
-spcwarp;90
-spdcoin;90
-speakhlp;270
-speakres;270
-speakresb;270
-spec2k;270
-speedbal;270
-speedbala;270
-spfghmk2;90
-spfghmk22;90
-spiders;270
-spiders2;270
-spiders3;270
-spinkick;90
-spinner;270
-sprglbpg;90
-sprglobp;90
-springer;270
-sprpuzzle;270
-spyhunt;90
-spyhuntp;90
-spyhuntpr;90
-spyhuntsp;90
-sqbert;270
-sqix;90
-sqixb1;90
-sqixb2;90
-sqixr0;90
-sqixr1;90
-sqixu;90
-sraider;270
-srdarwin;270
-srdarwinj;270
-srdmissn;90
-srumbler;270
-srumbler2;270
-srumbler3;270
-ssi;270
-ssia;270
-ssib;270
-ssingles;90
-ssmissin;270
-ssozumo;270
-sspacaho;270
-sspaceat;270
-sspaceat2;270
-sspaceat3;270
-sspaceatc;270
-sspeedr;270
-sspiritj;270
-sspirits;270
-sspirtfc;270
-ssrj;90
-sstarbtl;90
-sstarcrs;90
-sstingry;90
-sstrangr;270
-sstrangr2;270
-sstriker;270
-sstrikerk;270
-stagger1;270
-starfght;90
-starfgmc;90
-starfigh;90
-starforc;90
-starforca;90
-starforcb;90
-starforce;90
-starjack;270
-starjacks;270
-starrkr;270
-starw;270
-starw1;270
-starwarr;270
-step3;270
-stepstag;270
-stera;90
-stg;270
-stinger;90
-stinger2;90
-stmblade;270
-stmbladej;270
-storming;270
-storminga;270
-stratab;270
-stratab1;270
-stratabs;270
-stratvox;270
-stratvoxa;270
-stratvoxb;270
-streakng;90
-streaknga;90
-strfbomb;90
-strfight;90
-strtheat;270
-strtheata;270
-stuntair;90
-sub;270
-sundance;270
-superabc;90
-superabco;90
-superbar;270
-superbik;90
-superbon;90
-superbug;270
-superg;90
-supergs;90
-supergx;90
-superinv;270
-superpac;90
-superpacm;90
-supertnk;90
-superwng;90
-superx;270
-superxm;270
-supinvsion;270
-supmodel;90
-suprglob;270
-suprheli;90
-suprmous;90
-suprridr;90
-survival;90
-swarm;90
-swat;270
-swipeout;270
-sxevious;90
-sxeviousj;90
-szaxxon;90
-tacscan;270
-tactcian;90
-tactcian2;90
-tagteam;270
-tail2nos;90
-takeoff;90
-talbot;270
-tankbatt;90
-tankbattb;90
-tankbust;90
-tattack;270
-taxidriv;90
-tazmani2;90
-tazmani3;90
-tazmania;90
-tazmaniet;90
-tazzmang;90
-tazzmang2;90
-tcobra2;270
-tcobra2u;270
-tdfever;90
-tdfever2;90
-tdfever2b;90
-tdfeverj;90
-tdragon;270
-tdragon1;270
-tdragon2;270
-tdragon2a;270
-tdragon3h;270
-tdragonb;270
-tdragonb2;270
-teamqb;270
-teamqb2;270
-teedoff;90
-telpacfl;270
-tempest;270
-tempest1;270
-tempest1r;270
-tempest2;270
-tempest3;270
-temptube;270
-tenkomor;90
-tenkomorja;90
-tenspot;270
-terracre;270
-terracrea;270
-terracren;270
-terracreo;270
-tharrier;270
-tharrierb;270
-tharrieru;270
-the26thz;90
-thedeep;270
-theend;90
-theends;90
-theendss;90
-theglob;270
-theglob2;270
-theglob3;270
-theglobme;90
-theglobp;90
-thehand;270
-thehole;90
-thehustl;90
-thehustlj;90
-thepit;90
-thepitj;90
-thepitm;90
-thepitu1;90
-thepitu2;90
-theportr;90
-thndblst;270
-thunderl;270
-thunderlbl;270
-thunderlbl2;270
-tigerh;270
-tigerhb1;270
-tigerhb2;270
-tigerhb3;270
-tigerhb4;270
-tigerhj;270
-timefgtr;90
-timelimt;90
-timeplt;90
-timeplta;90
-timepltc;90
-timescan;270
-timescan1;270
-timesold;90
-timesold1;90
-tinv2650;270
-tiptop;90
-tisland;270
-titanpac;90
-tndrcade;270
-tndrcadej;270
-tnextspc;90
-tnextspc2;90
-tnextspcj;90
-tnk3;270
-tnk3b;270
-tnk3j;270
-todruaga;90
-todruagao;90
-todruagas;90
-tokio;90
-tokiob;90
-tokioo;90
-tokiou;90
-tokisens;90
-tokisensa;90
-tomahawk;90
-tomahawk1;90
-toobin;270
-toobin1;270
-toobin2;270
-toobin2e;270
-toobine;270
-toobing;270
-topgunbl;90
-topgunr;90
-toprollr;90
-toratora;90
-toratorab;90
-torus;90
-toucheme;90
-touchemea;90
-tp84;90
-tp84a;90
-tp84b;90
-tranqgun;270
-travrusa;270
-travrusab;270
-travrusab2;270
-treahunt;90
-trgheart;270
-trghearto;270
-tricktrp;90
-trigon;90
-trikitri;90
-triplep;90
-triplepa;90
-tripool;90
-tripoola;90
-trisport;270
-trizeal;270
-tron;90
-tron2;90
-tron3;90
-tron4;90
-tron5;90
-tronger;90
-troopy;90
-truxton;270
-truxton2;270
-tryout;90
-tsamurai;90
-tsamurai2;90
-tsamuraih;90
-ttfitter;90
-tugboat;90
-tunhunt;90
-tunhuntc;90
-turbo;270
-turboa;270
-turbob;270
-turbobl;270
-turboc;270
-turbod;270
-turboe;270
-turbofrc;270
-turbofrco;270
-turbofrcu;270
-turbotag;90
-turpin;90
-turpins;90
-turtles;90
-tutankhm;90
-tutankhms;90
-twinbee;90
-twinbeeb;90
-twincobr;270
-twincobru;270
-twineag2;270
-twineagl;270
-twinhawk;270
-twinhawku;270
-typhoon;90
-ultrainv;270
-ultrax;270
-ultraxg;270
-unclepoo;90
-undefeat;270
-uniwars;90
-uniwarsa;90
-upndown;270
-upndownu;270
-upyoural;270
-usclssic;270
-valkyrie;90
-valtric;270
-vandyke;270
-vandykeb;270
-vandykejal;270
-vandykejal2;270
-vangrd2;270
-vanguard;90
-vanguardc;90
-vanguardg;90
-vanguardj;90
-vanvan;270
-vanvanb;270
-vanvank;270
-vaportra;270
-vaportra3;270
-vaportrau;270
-varth;270
-varthb;270
-varthb2;270
-varthj;270
-varthjr;270
-varthr1;270
-varthu;270
-vasara;270
-vasara2;270
-vasara2a;270
-vastar;90
-vastar2;90
-vastar3;90
-vastar4;90
-vautour;90
-vautourz;90
-vautourza;90
-vega;270
-venus;90
-verbena;270
-vfive;270
-victoryc;270
-victorycb;270
-victroad;270
-videight;90
-videopin;270
-videopina;270
-vimana;270
-vimanaj;270
-vimanan;270
-viprp1;270
-viprp1h;270
-viprp1hk;270
-viprp1j;270
-viprp1k;270
-viprp1oj;270
-viprp1ot;270
-viprp1pt;270
-viprp1s;270
-viprp1t;270
-viprp1u;270
-viprp1ua;270
-vmetal;90
-vmetaln;90
-volfied;270
-volfiedj;270
-volfiedjo;270
-volfiedo;270
-volfiedu;270
-volfieduo;270
-vortex;270
-vpool;90
-vsgongf;90
-vulgus;270
-vulgusa;90
-vulgusj;270
-waidrive;270
-wallst;90
-wanted;90
-wantsega;270
-warcadia;270
-warofbug;90
-warofbugg;90
-warofbugu;90
-warp1;270
-warp1bl;270
-warp1t;90
-warpwarp;90
-warpwarpr;90
-warpwarpr2;90
-waterski;270
-westgun2;270
-wiggie;270
-wingforc;270
-wiping;90
-wivernwg;270
-wiz;270
-wizt;270
-wizta;270
-wmatch;270
-wndrplnt;270
-woodpeca;90
-woodpeck;90
-worldwar;270
-wrestwar;270
-wrestwar1;270
-wrestwar1d;270
-wrestwar2;270
-wrestwar2d;270
-wrofaero;270
-wtennis;270
-ww3;270
-wwestern;270
-wwestern1;270
-wyvernf0;270
-wyvernf0a;270
-wyvernwg;270
-wyvernwga;270
-x2222;270
-x2222o;270
-xevios;90
-xevious;90
-xeviousa;90
-xeviousb;90
-xeviousc;90
-xiistag;270
-xxmissio;90
-yamagchi;90
-yamato;90
-yamato2;90
-yankeedo;270
-yellowcbb;270
-yellowcbj;270
-yosakdon;270
-yosakdona;270
-youjyudn;270
-youma;270
-youma2;270
-youmab;270
-youmab2;270
-yukiwo;270
-zaryavos;90
-zarzon;90
-zaviga;90
-zavigaj;90
-zaxxon;90
-zaxxon2;90
-zaxxon3;90
-zaxxonb;90
-zaxxonj;90
-zerohour;270
-zerohoura;270
-zerohouri;270
-zerotime;90
-zerotimed;90
-zerotimemc;90
-zerotimeu;90
-zigzagb;90
-zigzagb2;90
-zingzip;270
-zingzipbl;270
-zipzap;90
-zipzapa;90
-zoar;270
-zodiack;270
-zzyzzyxx;90
-zzyzzyxx2;90
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/lynx.csv b/package/batocera/core/batocera-configgen/data/special/lynx.csv
deleted file mode 100755
index 0dffa0b0693..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/lynx.csv
+++ /dev/null
@@ -1,5 +0,0 @@
-Gauntlet - The Third Encounter (USA, Europe);270
-Klax (USA, Europe);270
-Lexis (USA);90
-NFL Football (USA, Europe);90
-Raiden (USA);90
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/mame.csv b/package/batocera/core/batocera-configgen/data/special/mame.csv
deleted file mode 100755
index faceeea0181..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/mame.csv
+++ /dev/null
@@ -1,2957 +0,0 @@
-1941;270
-1941j;270
-1941r1;270
-1941u;270
-1942;270
-1942a;270
-1942abl;270
-1942b;270
-1942h;270
-1942p;270
-1942w;270
-1943;270
-1943b;270
-1943bj;270
-1943j;270
-1943ja;270
-1943jah;270
-1943kai;270
-1943mii;270
-1943u;270
-1943ua;270
-1945kiii;270
-1945kiiin;270
-1945kiiio;270
-19in1;90
-19xx;270
-19xxa;270
-19xxar1;270
-19xxb;270
-19xxd;270
-19xxh;270
-19xxj;270
-19xxjr1;270
-19xxjr2;270
-19xxu;270
-20pacgal;90
-20pacgalr0;90
-20pacgalr1;90
-20pacgalr2;90
-20pacgalr3;90
-20pacgalr4;90
-25pacman;90
-25pacmano;90
-39in1;90
-48in1;90
-48in1a;90
-48in1b;90
-48in1c;90
-4in1;90
-4in1a;90
-4in1b;90
-5;270
-600;90
-60in1;90
-800fath;90
-800fatha;90
-8ball;90
-8ball1;90
-8ballact;270
-8ballact2;270
-8bpm;90
-99lstwar;90
-99lstwara;90
-99lstwarb;90
-99lstwark;90
-aafb;270
-aafbb;270
-aafbc;270
-aafbd2p;270
-abattle;90
-abattle2;90
-abscam;90
-aceattac;270
-aceattaca;270
-acombat;90
-acombat3;90
-acombat4;90
-acombato;90
-acrobatm;270
-ad2083;90
-aerfboo2;270
-aerfboot;270
-aerofgt;270
-aerofgtb;270
-aerofgtc;270
-aerofgts;270
-afighter;270
-afightera;270
-afighterb;270
-afighterc;270
-afighterd;270
-afightere;270
-afighterf;270
-afighterg;270
-afighterh;270
-afire;90
-agallet;270
-agalleta;270
-agalletah;270
-agalletaj;270
-agalletak;270
-agalletat;270
-agalletau;270
-agalleth;270
-agalletj;270
-agalletk;270
-agallett;270
-agalletu;270
-airass;270
-airattck;270
-airattcka;270
-airduel;270
-airdueljm72;270
-airduelm72;270
-airduelu;270
-airraid;270
-ajax;90
-ajaxj;90
-akkanvdr;270
-alcon;270
-alibaba;90
-alibabab;90
-alieninv;270
-alieninvp2;270
-alinvade;90
-alleymas;270
-alphaho;270
-alphahob;270
-alphamis;270
-alphaxz;90
-alpine;270
-alpinea;270
-altair;90
-amatelas;270
-amazon;270
-amazont;270
-amidar;90
-amidar1;90
-amidarb;90
-amidarc;90
-amidaro;90
-amidars;90
-amidaru;90
-amigo;90
-amigo2;90
-androidp;90
-androidpo;90
-andromed;270
-angelkds;90
-anmlbskt;270
-anmlbskta;270
-anteater;90
-anteaterg;90
-anteatergg;90
-anteateruk;90
-apb;270
-apb1;270
-apb2;270
-apb3;270
-apb4;270
-apb5;270
-apb6;270
-apbf;270
-apbg;270
-aponow;90
-arabian;270
-arabiana;270
-aracnis;90
-arbalest;270
-arcadian;270
-argus;270
-arian;270
-ark1ball;90
-arkangc;90
-arkangc2;90
-arkanoid;90
-arkanoidj;90
-arkanoidja;90
-arkanoidjb;90
-arkanoidjbl;90
-arkanoidjbl2;90
-arkanoidu;90
-arkanoiduo;90
-arkatayt;90
-arkatour;90
-arkbloc2;90
-arkbloc3;90
-arkblock;90
-arkgcbl;90
-arkgcbla;90
-arknoid2;270
-arknoid2b;270
-arknoid2j;270
-arknoid2u;270
-arktayt2;90
-armchmp2;270
-armchmp2o;270
-armchmp2o2;270
-armedf;270
-armedff;270
-armorcar;90
-armorcar2;90
-ashura;270
-ashuraj;270
-ashurau;270
-asideral;90
-aso;270
-assault;90
-assaultj;90
-assaultp;90
-asterion;90
-astinvad;270
-astrians;90
-astroamb;90
-astrob;270
-astrob1;270
-astrob2;270
-astrob2a;270
-astrob2b;270
-astrobg;270
-astrof;90
-astrof2;90
-astrof3;90
-astroff;90
-astroff2;90
-astrowar;90
-asuka;270
-asukaj;270
-asukaja;270
-asylum;270
-ataqandr;90
-atetrisc;270
-atetrisc2;270
-atlantis;90
-atlantis2;90
-atlantisb;270
-atombjt;270
-attckufo;270
-avefenix;90
-avefenixl;90
-avefenixrf;90
-avengers;90
-avengers2;90
-azurian;90
-bagman;270
-bagmanf;270
-bagmanj;270
-bagmanm2;90
-bagmanmc;90
-bagmans;270
-bagmans3;270
-bagmans4;270
-bagnard;270
-bagnarda;270
-bagnardi;90
-bagnardio;90
-bakubrkr;90
-ballbomb;270
-baluba;90
-bandido;270
-bandit;90
-baracuda;90
-barrier;270
-baryon;270
-baryona;270
-batman2;270
-batrider;270
-batriderc;270
-batriderhk;270
-batriderj;270
-batriderja;270
-batriderk;270
-batridert;270
-batrideru;270
-batsugun;270
-batsuguna;270
-batsugunb;270
-batsugunsp;270
-battlane;90
-battlane2;90
-battlane3;90
-battles;90
-battles2;90
-battlnts;90
-battlntsa;90
-battlntsj;90
-battroad;90
-bbakraid;270
-bbakraidc;270
-bbakraidj;270
-bbakraidja;270
-bcruzm12;90
-beaminv;270
-beastf;270
-beastfp;90
-beezer;90
-beezer1;90
-berenstn;90
-bermudat;270
-bermudata;270
-bermudatj;270
-bgaregga;270
-bgareggabl;270
-bgareggabla;270
-bgareggacn;270
-bgareggahk;270
-bgaregganv;270
-bgareggat;270
-bgareggat2;270
-bgareggatw;270
-bigbang;270
-bigevglf;270
-bigevglfj;270
-bigkong;270
-bigprowr;270
-bikkuric;90
-billiard;90
-bioatack;270
-birdiy;270
-birdtry;270
-birdtrya;270
-bjtwin;270
-bjtwina;270
-bjtwinp;270
-bjtwinpa;270
-bking;270
-bking2;90
-bking3;90
-blackhol;270
-bladestl;90
-bladestle;90
-bladestll;90
-blastoff;90
-blazer;90
-blkbustr;90
-blkhole;90
-block;270
-block2;90
-blockbl;270
-blockcar;90
-blockcarb;90
-blockch;270
-blockgal;90
-blockgalb;90
-blockj;270
-blockr1;270
-blockr2;270
-blokpong;270
-blswhstl;90
-blswhstla;90
-bluehawk;270
-bluehawkn;270
-blueprnt;270
-blueprntj;270
-bnj;270
-bnjm;270
-boggy84;90
-boggy84b;90
-boggy84b2;90
-bombbee;90
-bomber;90
-bombjack;90
-bombjack2;90
-bombjackt;90
-bombsa;270
-bongo;90
-boobhack;270
-boomrang;270
-boomranga;270
-boonggab;270
-botanic;90
-botanic2;90
-botanicf;270
-bowl3d;90
-bowler;90
-bowlrama;270
-brdrlinb;270
-brdrline;270
-brdrlinet;270
-brdrlins;270
-breakout;90
-brickzn;90
-brickzn11;90
-brickznv4;90
-brickznv5;90
-brix;90
-bronx;90
-brubber;270
-brvblade;270
-brvbladea;270
-brvbladej;270
-brvbladeu;270
-btime;270
-btime2;270
-btime3;270
-btimem;270
-btlfield;90
-btlfieldb;90
-bucaner;90
-bucanera;90
-buggychl;270
-buggychlt;270
-bullfgtr;90
-bullfgtrs;90
-bullsdrt;270
-bullsdrtg;90
-buraiken;90
-buraikenb;90
-buzzard;90
-bwings;90
-bwingsa;90
-bwingso;90
-cadanglr;270
-cairblad;270
-calibr50;270
-calipso;90
-cannballv;270
-cannonb;90
-cannonb2;90
-cannonb3;90
-cannonbp;90
-capbowl;270
-capbowl2;270
-capbowl3;270
-capbowl4;270
-capitol;90
-caractn;270
-caractn2;270
-carhntds;270
-carjmbre;90
-carnival;270
-carnivalb;270
-carnivalc;270
-carnivalca;270
-carnivalh;270
-carnivalha;270
-castfant;270
-catacomb;90
-catapult;270
-caterplr;270
-catnmous;90
-catnmousa;90
-cavelon;90
-cbdash;270
-cbnj;270
-cbtime;270
-cburnrub;270
-cburnrub2;270
-cburnrubj;270
-cchasm;270
-cchasm1;270
-cdiscon1;270
-cencourt;270
-centipdb;270
-centipdb2;270
-centipdd;270
-centiped;270
-centiped1;270
-centiped2;270
-centiped3;270
-centipedj;270
-cexplore;270
-cfarm;90
-cfboy0a1;270
-cfghtice;270
-cfishing;270
-cflyball;270
-cgraplop;270
-cgraplop2;270
-cgraplopj;270
-chamburger;270
-champbwl;270
-chanbara;270
-checkman;90
-checkmanj;90
-cheekyms;270
-chinhero;90
-chinhero2;90
-chinhero3;90
-chinherot;90
-chkun;90
-chopper;270
-choppera;270
-chopperb;270
-chqflag;90
-chqflagj;90
-chwy;270
-circusc;90
-circusc2;90
-circusc3;90
-circusc4;90
-circuscc;90
-circusce;90
-cityatta;90
-citybomb;270
-citybombj;270
-ckong;270
-ckongalc;270
-ckongcv;90
-ckongdks;270
-ckongg;90
-ckonggx;90
-ckongis;90
-ckongmc;90
-ckongmc2;90
-ckongo;270
-ckongpt2;270
-ckongpt2a;270
-ckongpt2b;270
-ckongpt2b2;270
-ckongpt2j;270
-ckongpt2jeu;270
-ckongpt2ss;270
-ckongs;90
-clapapa;270
-clapapa2;270
-clbowl;270
-clocknch;270
-clocknchj;270
-clubpacm;90
-clubpacma;90
-cmanhat;270
-cmissnx;270
-cmmb103;270
-cmmb162;270
-cnightst;270
-cnightst2;270
-cntsteer;270
-cocoloco;90
-cocolocoa;90
-cocolocob;90
-colony7;270
-colony7a;270
-combh;270
-commando;270
-commandob;270
-commandob2;270
-commandob3;270
-commandoj;270
-commandou;270
-commandou2;270
-commsega;90
-complexx;270
-condor;90
-condorn;90
-congo;90
-congoa;90
-conquer;90
-contra;90
-contra1;90
-contrab;90
-contrabj;90
-contrabj1;90
-contrae;90
-contraj;90
-contraj1;90
-cookrace;270
-coozumou;270
-cosmica;270
-cosmica1;270
-cosmica22;270
-cosmica23;270
-cosmica2a;270
-cosmicg;270
-cosmicgi;270
-cosmicin;270
-cosmicm2;270
-cosmicmo;270
-cosmo;90
-cosmogng;90
-cosmogngj;90
-cosmos;90
-cottong;90
-cowtipp;270
-cowtippa;270
-cppicf;270
-cppicf2;270
-cprobowl;270
-cprogolf;270
-cprogolf18;270
-cprogolfj;270
-cpsoccer;270
-cpsoccerj;270
-cptennis;270
-cptennisj;270
-crashrd;270
-crazyblk;90
-crazycop;90
-crbaloon;90
-crbaloon2;90
-crockman;90
-crockmnf;90
-crshrace;270
-crshrace2;270
-crush;90
-crush2;90
-crush3;90
-crush4;90
-crush5;90
-crushbl;90
-crushbl2;90
-crushbl3;90
-crushrlf;90
-crushs;90
-crzrally;270
-crzrallya;270
-crzrallyg;270
-crzrallyrf;270
-cscrtry;270
-cscrtry2;270
-csdtenis;270
-cshooter;270
-cshootert;270
-cskater;270
-csuperas;270
-csweetht;270
-ctainv;270
-cterrani;270
-ctisland;270
-ctisland2;270
-ctisland3;270
-ctornado;270
-ctrpllrp;90
-curvebal;270
-cutieq;90
-cybattlr;90
-cyclshtg;90
-cyvern;90
-cyvernj;90
-czeroize;270
-dai3wksi;270
-daikaiju;270
-daioh;270
-daioha;270
-daiohc;270
-daiohp;270
-daiohp2;270
-daisenpu;270
-dambustr;90
-dambustra;90
-dambustruk;90
-dangar;270
-dangara;270
-dangarb;270
-dangarbt;270
-dangarj;270
-dangseed;90
-darkmist;270
-darkwar;90
-darthvdr;270
-darwin;270
-dazzler;90
-dblewing;90
-dblewinga;90
-dblewingb;90
-ddaydoo;270
-ddayjlc;90
-ddayjlca;90
-ddonpach;270
-ddonpacha;270
-ddonpachj;270
-ddp2;270
-ddp2100;270
-ddp2100c;270
-ddp2100hk;270
-ddp2100j;270
-ddp2100k;270
-ddp2100t;270
-ddp2101;270
-ddp2101c;270
-ddp2101hk;270
-ddp2101j;270
-ddp2101k;270
-ddp2101t;270
-ddp2c;270
-ddp2hk;270
-ddp2j;270
-ddp2k;270
-ddp2t;270
-ddp3;270
-ddpdfk;270
-ddpdfk10;270
-ddpdoj;270
-ddpdoja;270
-ddpdojb;270
-ddpdojblk;270
-ddpdojblka;270
-ddpdojblkbl;270
-ddpdojp;270
-ddpdojt;270
-decomult;270
-demoneye;270
-desertbr;270
-desertbrd;270
-desertbrj;270
-desertbrjd;270
-desertwr;270
-desterth;270
-destryer;90
-destryera;90
-detatwin;90
-devilfsg;270
-devilfsh;90
-devstors;90
-devstors2;90
-devstorsv;90
-devstorsx;90
-devzone;270
-devzone2;270
-dfeveron;270
-dfkbl;270
-digdug;90
-digdug1;90
-digdug2;90
-digdug2o;90
-digdugat;90
-digdugat1;90
-digger;270
-diggerc;90
-digsid;90
-dimahoo;270
-dimahoou;270
-dimahoud;270
-dingo;90
-dingoe;90
-dirtfoxj;90
-disco;270
-discoboy;270
-discoboyp;270
-discof;270
-divebomb;270
-dking;270
-dkingjr;270
-dkmb;270
-dkong;270
-dkong3;270
-dkong3abl;90
-dkong3b;270
-dkong3hs;270
-dkong3j;270
-dkongddk;270
-dkongf;270
-dkonghrd;270
-dkonghs;270
-dkongike;270
-dkongj;270
-dkongjnrj;270
-dkongjo;270
-dkongjo1;270
-dkongjr;270
-dkongjr2;270
-dkongjrb;270
-dkongjrc;270
-dkongjre;270
-dkongjrhs;270
-dkongjrj;270
-dkongjrm;90
-dkongjrmc;90
-dkongjrpb;270
-dkongo;270
-dkongpe;270
-dkongx;270
-dkongx11;270
-docastle;270
-docastle2;270
-docastleo;270
-dockman;90
-dockmanb;90
-dockmanc;90
-dogfight;270
-dogfightp;270
-dogosokb;270
-dogosoke;270
-dogyuun;270
-dogyuuna;270
-dogyuunt;270
-dommy;270
-donpachi;270
-donpachihk;270
-donpachij;270
-donpachijs;270
-donpachikr;270
-dorachan;270
-dorodon;270
-dorodon2;270
-douni;270
-downtown;270
-downtown2;270
-downtownj;270
-downtownp;270
-draco;90
-dragnblz;270
-drakton;270
-dremshpr;270
-driftout;270
-driftoutct;270
-driftoutj;270
-driveout;270
-drivfrcb;90
-drivfrcg;90
-drivfrcp;90
-drivfrcsg;90
-drivfrcsga;90
-drivfrct;90
-drktnjr;270
-drmicro;270
-drtoppel;90
-drtoppelj;90
-drtoppelu;90
-dsaber;90
-dsabera;90
-dsaberj;90
-dspirit;90
-dspirit1;90
-dspirit2;90
-dualaslt;270
-dyger;270
-dygera;270
-dynamski;90
-dzigzag;90
-eagle;90
-eagle2;90
-eagle3;90
-earthjkr;270
-earthjkra;270
-earthjkrb;270
-earthjkrp;270
-eeekk;270
-eeekkp;90
-efdt;90
-eggor;90
-eggs;270
-eightfrc;90
-elecyoyo;270
-elecyoyo2;270
-enigma2;270
-enigma2a;270
-enigma2b;270
-equites;90
-equitess;90
-escmars;270
-espgal;270
-espgal2;270
-espgal2a;270
-espgalbl;270
-esprade;270
-espradej;270
-espradejo;270
-evilston;270
-excthour;270
-exctscc2;270
-exctsccr;270
-exctsccra;270
-exctsccrb;270
-exctsccrj;270
-exctsccrjo;270
-exctsccru;270
-exedexes;270
-exerion;90
-exerionb;90
-exerionb2;90
-exerionba;90
-exeriont;90
-exerizer;90
-exerizerb;90
-exodus;90
-explbrkr;90
-explbrkrk;90
-explorer;90
-extrmatn;270
-extrmatnj;270
-extrmatnu;270
-extrmatnur;270
-eyes;90
-eyes2;90
-eyesb;90
-eyeszac;90
-eyeszacb;90
-f1gp;90
-f1gp2;90
-f1gpa;90
-f1gpb;90
-fa;90
-falcon;90
-falconz;90
-fantasia;90
-fantasiaa;90
-fantasiab;90
-fantasian;90
-fantastc;90
-fantasyg;90
-fantasyg2;90
-fantasyj;90
-fantasyu;90
-fantazia;90
-fantsy95;90
-fastdraw;90
-fastdraw_130;90
-fastfred;90
-fastlane;90
-fcombat;90
-fenix;90
-fenixn;90
-feversos;270
-fghtatck;90
-fgoal;90
-fgoala;90
-fhawk;270
-fhawkj;270
-fi6845;270
-fi8275;270
-fightrol;270
-finalizr;90
-finalizra;90
-finalizrb;90
-firebarr;270
-firebatl;90
-fireshrk;270
-fireshrka;270
-fireshrkd;270
-fireshrkdh;270
-firetrap;90
-firetrapa;90
-firetrapbl;90
-firetrapj;90
-firetrk;270
-fitter;90
-fitterbl;90
-fiveside;90
-fixeight;270
-fixeighta;270
-fixeightat;270
-fixeightbl;270
-fixeighth;270
-fixeightht;270
-fixeightj;270
-fixeightjt;270
-fixeightk;270
-fixeightkt;270
-fixeightt;270
-fixeighttw;270
-fixeighttwt;270
-fixeightu;270
-fixeightut;270
-flipjack;270
-flkatck;90
-flkatcka;90
-floritas;90
-floritasm;90
-flyboy;90
-flyboyb;90
-flytiger;270
-flytigera;270
-fnkyfish;90
-fnshark;270
-fourplay;90
-freekick;270
-freekicka;270
-freekickb1;270
-freekickb2;270
-freekickb3;270
-freeze;90
-frogf;90
-frogg;90
-frogger;90
-froggeram;90
-froggermc;90
-froggers;90
-froggers1;90
-froggers2;90
-froggers3;90
-froggerv;90
-froggervd;90
-froggrs;90
-frontlin;270
-frontlina;270
-fshark;270
-fsharkb;270
-fsharkbt;270
-fspiderb;90
-fstarfrc;90
-fstarfrcj;90
-fstarfrcw;90
-funkybee;90
-funkybeeb;90
-funnymou;90
-futari10;270
-futari15;270
-futari15a;270
-futaribl;270
-futariblj;270
-futflash;270
-futspy;90
-fx;90
-gaiapols;90
-gaiapolsj;90
-gaiapolsu;90
-galactic;270
-galaga;90
-galaga3;90
-galaga3a;90
-galaga3b;90
-galaga3c;90
-galaga3m;90
-galaga88;270
-galaga88a;90
-galaga88j;90
-galagamf;90
-galagamk;90
-galagamw;90
-galagao;90
-galaktron;90
-galap1;90
-galap4;90
-galapx;90
-galaxbsf;90
-galaxbsf2;90
-galaxcirsa;90
-galaxia;90
-galaxiaa;90
-galaxiab;90
-galaxiac;90
-galaxian;90
-galaxiana;90
-galaxianbl;90
-galaxianbl2;90
-galaxianbl3;90
-galaxianem;90
-galaxiani;90
-galaxianm;90
-galaxianmo;90
-galaxianrp;90
-galaxiant;90
-galaxrcgg;90
-galaxrf;90
-galaxrfgg;90
-galaxygn;90
-galaxygnh;90
-galaxyx;90
-galemp;90
-galgame;270
-galivan;270
-galivan2;270
-galivan3;270
-galkamika;90
-gallag;90
-galmedes;270
-galmonst;270
-galpani2;90
-galpani2e;90
-galpani2e2;90
-galpani2g;90
-galpani2gs;90
-galpani2i;90
-galpani2i2;90
-galpani2j;90
-galpani2t;90
-galpani3;90
-galpani3hk;90
-galpani3j;90
-galpani3k;90
-galpanic;90
-galpanica;90
-galpanicb;90
-galpanicms;90
-galsnew;90
-galsnewj;90
-galsnewk;90
-galsnewt;90
-galsnewu;90
-galspnbl;90
-galturbo;90
-galxwars;270
-galxwars2;270
-galxwarst;270
-galxwarst2;270
-gametngk;270
-gangonta;90
-gaplus;90
-gaplusa;90
-gaplusd;90
-gaplust;90
-gardia;270
-gardiab;270
-gardiaj;270
-garuka;90
-gatsbee;90
-gbusters;90
-gbustersa;90
-gcpinbal;270
-geebee;90
-geebeea;90
-geebeeb;90
-geebeeg;90
-gekiridn;270
-gekiridnj;270
-gekisou;90
-gemcrush;270
-gemini;90
-geminib;90
-geminij;90
-getaway;270
-gground;270
-ggroundj;270
-ghostmun;90
-ghox;270
-ghoxj;270
-ghoxjo;270
-gigas;270
-gigasb;270
-gigasm2;270
-gigasm2b;270
-gmahou;270
-gmgalax;90
-gnbarich;270
-goindol;90
-goindolj;90
-goindolu;90
-goldbug;90
-gondo;270
-gondou;270
-gorf;270
-gorfpgm1;270
-gorfpgm1g;270
-gorkans;90
-gotya;270
-gp2quiz;90
-gp2se;90
-grasspin;270
-grchamp;270
-grchampa;270
-grchampb;270
-grdnstrmg;270
-grdnstrmj;270
-grdnstrmk;270
-grdnstrmv;270
-greenber;270
-grescue;270
-griffon;90
-griffono;90
-grindstm;270
-grindstma;270
-grobda;90
-grobda2;90
-grobda3;90
-gryzor;90
-gryzor1;90
-gseeker;90
-gseekerj;90
-gseekeru;90
-gstream;270
-gteikoku;90
-gteikokub;270
-gteikokub2;90
-gteikokub3;90
-gulfstrm;270
-gulfstrma;270
-gulfstrmb;270
-gulfstrmk;270
-gulfstrmm;270
-gulfwar2;270
-gulfwar2a;270
-gunball;270
-gunbird;270
-gunbird2;270
-gunbird2a;270
-gunbirdj;270
-gunbirdk;270
-gundealr;270
-gundealra;270
-gundealrbl;270
-gundealrt;270
-gundhara;270
-gundharac;270
-gunfront;270
-gunfrontj;270
-gunlock;90
-gunlocko;270
-gunnail;270
-gunnailb;270
-gunnailp;270
-gunsmoke;270
-gunsmokeb;270
-gunsmokeg;270
-gunsmokej;270
-gunsmokeu;270
-gunsmokeua;270
-gunsmokeub;270
-gutangtn;90
-guttangt;90
-guttangts3;90
-guwange;270
-guwanges;270
-guzzler;90
-guzzlers;90
-gwar;270
-gwara;270
-gwarab;270
-gwarb;270
-gwarj;270
-gyrodine;90
-gyrodinet;90
-gyruss;90
-gyrussb;90
-gyrussce;90
-hal21;270
-hal21j;270
-halleysc;90
-halleysc87;90
-halleyscj;90
-halleyscja;90
-halleyscjp;90
-hamaway;90
-hangly;90
-hangly2;90
-hangly3;90
-harem;90
-hbarrel;270
-hbarrelu;270
-headoni;270
-headonn;270
-heartatk;90
-heiankyo;270
-helifire;270
-helifirea;270
-herbiedk;270
-hero;90
-herodk;270
-herodku;270
-hexpool;90
-hexpoola;90
-highsplt;270
-highsplta;270
-highspltb;270
-hishouza;270
-hishouzab;270
-hncholms;90
-hoccer;90
-hoccer2;90
-homo;90
-hopprobo;90
-horekid;270
-horekidb;270
-horshoes;270
-hotdogst;90
-hotnight;90
-hotpinbl;90
-hotshock;90
-hotshockb;90
-hotsmash;90
-hstennis;90
-hstennis10;90
-hunchbak;90
-hunchbaka;90
-hunchbgb;90
-hunchbkd;270
-hunchbkg;90
-hunchbks;90
-hunchbks2;90
-huncholy;90
-hustler;90
-hustlerb;90
-hustlerb2;90
-hustlerb3;90
-hustlerb4;90
-hustlerb5;90
-hustlerb6;90
-hustlerd;90
-hwrace;270
-ibara;270
-ibarablk;270
-ibarablka;270
-igmo;270
-ikari;270
-ikaria;270
-ikaria2;270
-ikarijp;270
-ikarijpb;270
-ikarinc;270
-ikariram;270
-ikaruga;270
-illvelo;270
-imago;90
-imagoa;90
-imgfight;270
-imgfightb;270
-imgfightj;270
-imolagp;90
-imolagpo;90
-indianbt;270
-indianbtbr;270
-indy800;90
-intrepid;90
-intrepid2;90
-intrepidb;90
-intrepidb2;90
-intrepidb3;90
-intruder;270
-invad2ct;90
-invaddlx;270
-invader4;270
-invaderl;270
-invadernc;270
-invaders;270
-invadersem;270
-invadpt2;270
-invadpt2a;270
-invadpt2br;270
-invadrmr;270
-invasion;270
-invasiona;270
-invasiona2;270
-invasionb;270
-invasionrz;270
-invasionrza;270
-invcarht;270
-invds;270
-invho2;270
-invho2a;270
-invinco;270
-invmulti;270
-invmultim1a;270
-invmultim2a;270
-invmultim2c;270
-invmultim3a;270
-invmultip;270
-invmultis1a;270
-invmultis2a;270
-invmultis3a;270
-invmultit3d;270
-invqix;270
-invrvnge;270
-invrvngea;270
-invrvngeb;270
-invrvngedu;270
-invrvngegw;270
-ipminvad;270
-ipminvad1;270
-irion;90
-ixion;270
-jack;90
-jack2;90
-jack3;90
-jackal;90
-jackalbl;90
-jackalj;90
-jackalr;90
-jackler;90
-jackrabt;90
-jackrabt2;90
-jackrabts;90
-jcross;270
-jcrossa;270
-jin;90
-jjack;270
-joinem;90
-jollyjgr;90
-jongbou;90
-josvolly;90
-journey;90
-joust2;270
-joust2r1;270
-joyfulr;270
-joyman;90
-jrking;270
-jrpacman;90
-jrpacmanf;90
-jrpacmbl;90
-jspecter;270
-jspecter2;270
-jumpbug;90
-jumpbugb;90
-jumpcoas;90
-jumpcoasa;90
-jumpcoast;90
-jumpkun;90
-jumpshot;90
-jumpshotp;90
-jungler;90
-junglero;90
-junglers;90
-jungsub;90
-junofrst;90
-junofrstg;90
-kageki;90
-kagekih;90
-kagekij;90
-kagekiu;90
-kaitei;90
-kaitein;90
-kamakazi3;90
-kamikaze;270
-kamikazesp;90
-kamikazp;90
-kamikcab;270
-kangaroo;90
-kangarooa;90
-kangaroob;90
-kangarool;90
-kaos;270
-karateda;90
-karatedo;90
-karatevs;90
-karous;270
-kbh;270
-kbm;270
-kbm2nd;270
-kbm3rd;270
-kchamp;90
-kchamptec;90
-kchampvs;90
-kchampvs2;90
-kchampvs3;90
-kchampvs4;90
-ket;270
-ket1;270
-keta;270
-ketarr;270
-ketarr10;270
-ketarr15;270
-ketarr151;270
-ketarrf;270
-ketarrs15;270
-ketarrs151;270
-ketb;270
-ketbl;270
-kick;90
-kickc;90
-kicker;90
-kickman;90
-kikikai;90
-kingball;90
-kingballj;90
-kingdmgp;270
-kingofb;90
-kingofbj;90
-knightb;90
-knockout;90
-knockoutb;90
-knockoutc;90
-komemokos;270
-konam80a;90
-konam80j;90
-konam80k;90
-konam80s;90
-konam80u;90
-kong;90
-korosuke;90
-kosmokil;270
-krull;270
-krzybowl;270
-ktiger;270
-ktiger2;270
-kuhga;270
-kyros;90
-kyrosj;90
-labyrunr;90
-labyrunrk;90
-ladybug;270
-ladybugb;270
-ladybugb2;270
-ladybugg;270
-ladyfrog;90
-ladykill;90
-ladymstr;90
-ladymstr2;90
-lagirl;270
-lagunar;90
-laser;270
-laserbas;270
-laserbasa;270
-lasso;90
-lastday;270
-lastdaya;270
-lastduel;270
-lastduelb;270
-lastduelj;270
-lastduelo;270
-lastmisn;270
-lastmisnj;270
-lastmisnu5;270
-lastmisnu6;270
-leds2011;270
-leds2011p;270
-leds2011u;270
-ledstorm;270
-legion;270
-legionj;270
-legionjb;270
-legionjb2;270
-legofair;270
-lethalth;270
-levers;90
-lgtnfght;90
-lgtnfghta;90
-lgtnfghtu;90
-liberate;270
-liberateb;270
-lizwiz;90
-lnc;270
-locoboot;90
-locomotn;90
-logger;90
-loggerr2;90
-looping;90
-loopingv;90
-loopingva;90
-losttomb;90
-losttombh;90
-loverboy;90
-lrescue;270
-lrescuem;270
-lrescuem2;270
-lsasquad;270
-lupin3;270
-lupin3a;270
-lwings;90
-lwings2;90
-lwingsb;90
-lwingsj;90
-lwingsja;90
-m660;90
-m660b;90
-m660j;90
-mach9;270
-machomou;90
-macross;270
-macrossp;270
-madalien;270
-madaliena;270
-madalienb;270
-madgear;270
-madgearj;270
-madshark;270
-mag_day;90
-mag_time;90
-mag_war;90
-mag_wara;90
-magspot;270
-magspot2;270
-maguila;270
-magworm;270
-magworma;270
-mahoudai;270
-majest12j;270
-majest12u;270
-maketrax;270
-maketrxb;270
-makyosen;270
-mamonoro;270
-mandinga;90
-mandingac;90
-mandingaeg;90
-mandingarf;90
-maniach;270
-maniach2;270
-manybloc;270
-mappy;90
-mappyj;90
-marinedt;270
-mariner;90
-mars;90
-marsarf;90
-marvins;270
-masterw;270
-masterwj;270
-masterwu;270
-matmania;270
-mazeinv;270
-mazinger;90
-mazingerj;90
-mbrush;90
-megadon;270
-megaforc;90
-megaforcu;90
-megazone;90
-megazonea;90
-megazoneb;90
-megazoneh;90
-megazonei;90
-megazonej;90
-mercenario;270
-mercs;270
-mercsj;270
-mercsu;270
-mercsur1;270
-merlinmm;90
-metafox;270
-meteor;270
-meteora;90
-metlfrzr;270
-metlhawk;90
-metlhawkj;90
-mgolf;270
-mightguy;270
-mikie;270
-mikiehs;270
-mikiej;270
-mikiek;270
-millipdd;270
-milliped;270
-millpac;270
-mimonkey;90
-mimonkeyug;90
-mimonsco;90
-mimonscr;90
-mimonscra;90
-minefld;90
-minefldfe;90
-mirax;90
-miraxa;90
-misncrft;90
-misncrfta;90
-mjleague;270
-mlander;270
-mltiwars;90
-mmagic;270
-mmonkey;270
-mmonkeyj;270
-mmpork;270
-mnchmobl;270
-mocapb;90
-mocapbj;90
-mocapglf;90
-moegonta;90
-mofflott;270
-moguchan;270
-monkeyd;270
-monsterb;270
-monsterb2;270
-montecar;270
-monymony;90
-monymony2;90
-monzagp;270
-monzagpb;270
-moonal2;90
-moonal2b;90
-moonaln;90
-moonbase;270
-moonbasea;270
-mooncmw;90
-mooncptc;90
-mooncreg;90
-mooncrgx;270
-mooncrs2;90
-mooncrs3;90
-mooncrs4;90
-mooncrs5;90
-mooncrsb;90
-mooncrsl;90
-mooncrst;90
-mooncrstg;90
-mooncrsto;90
-mooncrstso;90
-mooncrstu;90
-mooncrstuk;90
-mooncrstuku;90
-mooncrstuu;90
-moonqsr;90
-moonwar;90
-moonwara;90
-motorace;270
-motos;90
-mototour;270
-mouncrst;90
-mouser;90
-mouserc;90
-mplanets;270
-mplanetsuk;270
-mrdo;270
-mrdobl;270
-mrdofabr;270
-mrdofix;270
-mrdot;270
-mrdoy;270
-mrdu;270
-mrflea;270
-mrjong;90
-mrkougar;90
-mrkougar2;90
-mrkougb;90
-mrkougb2;90
-mrlo;270
-mrsdyna;270
-mrtnt;90
-mrviking;270
-mrvikingj;270
-mschamp;90
-mschamps;90
-msheartb;90
-msisaac;270
-mspacii;90
-mspacii2;90
-mspacmab;90
-mspacmab2;90
-mspacmab3;90
-mspacmab4;90
-mspacman;90
-mspacmanbcc;90
-mspacmanbco;90
-mspacmanbg;90
-mspacmanbg2;90
-mspacmanbgd;90
-mspacmanbhe;90
-mspacmanbi;90
-mspacmanblt;90
-mspacmanblt2;90
-mspacmancr;90
-mspacmat;90
-mspacmbe;90
-mspacmbmc;90
-mspacmbn;90
-mspacmnf;90
-mspacpls;90
-mspactwin;90
-mspuzzle;90
-mspuzzlea;90
-mspuzzleb;90
-multiped;270
-mushisam;270
-mushisama;270
-mushisamb;270
-musicbal;270
-mustache;90
-mustachei;90
-mx5000;90
-myqbert;270
-mystston;270
-myststono;270
-myststonoi;270
-naughtyb;90
-naughtyba;90
-naughtybb;90
-naughtybc;90
-navarone;90
-ncentury;90
-ncv1;90
-ncv1j;90
-ncv1j2;90
-ncv2;90
-ncv2j;90
-nebulbee;90
-nebulray;90
-nebulrayj;90
-neobattl;270
-netwars;90
-newfant;90
-newfanta;90
-newpuc2;90
-newpuc2b;90
-newpuckx;90
-newsin7;90
-newsin7a;90
-nextfase;90
-nibbler;90
-nibbler6;90
-nibbler7;90
-nibbler8;90
-nibblera;90
-nibblero;90
-nibblerp;90
-nightmare;90
-ninjemak;270
-nitrobal;270
-nitrobala;270
-nitrobalb;270
-nmouse;90
-nmouseb;90
-noahsark;90
-nob;270
-nobb;270
-nomnlnd;270
-nomnlndg;270
-nost;270
-nostj;270
-nostk;270
-nsub;270
-numcrash;90
-nunchaku;90
-nyny;270
-nynyg;270
-offensiv;90
-ohpaipee;270
-oigas;270
-olibochu;270
-olibug;90
-olmandingc;90
-olmandingo;90
-omega;270
-omegaa;270
-omegab;270
-omegaf;270
-omegafs;270
-omni;90
-ooparts;270
-orbite;270
-orbitron;270
-outline;90
-outzone;270
-outzonea;270
-outzoneb;270
-outzonec;270
-outzonecv;270
-outzoneh;270
-overdriv;90
-overdriva;90
-overdrivb;90
-ozmawars;270
-ozmawars2;270
-ozmawarsmr;270
-ozon1;90
-p911;90
-p9112;90
-p911ac;90
-p911ea;90
-p911ed;90
-p911j;90
-p911k;90
-p911kc;90
-p911ud;90
-pac90;90
-pacgal;90
-pacgal2;90
-pacheart;90
-pachifev;270
-packbang;90
-pacman;90
-pacmanbl;270
-pacmanbla;270
-pacmanblb;90
-pacmanblc;270
-pacmanblci;270
-pacmanblv;270
-pacmanf;90
-pacmanfm;90
-pacmania;270
-pacmaniaj;90
-pacmaniao;270
-pacmanjpm;90
-pacmanpe;90
-pacmanso;90
-pacmansp;90
-pacmanug;90
-pacmanvg;90
-pacmod;90
-pacnchmp;90
-pacnpal;90
-pacnpal2;90
-pacominv;270
-pacominva;270
-pacplus;90
-pacuman;90
-paddle2;90
-paddlema;90
-painter;90
-paintrlr;90
-pairlove;270
-pajaroes;90
-pandoras;90
-panic;270
-panic2;270
-panic3;270
-panicger;270
-panich;270
-panicr;270
-panicrg;270
-panther;270
-paprazzi;270
-para2dx;90
-paradise;90
-paradisea;90
-paradisee;90
-paradlx;90
-passht4b;270
-passsht;270
-passsht16a;270
-passshta;270
-passshtad;270
-passshtb;270
-passshtd;270
-passshtj;270
-passshtjd;270
-pbaction;90
-pbaction2;90
-pbaction3;90
-pbaction4;90
-pbactiont;90
-pballoon;90
-pballoonr;90
-peggle;90
-pegglet;90
-pengo;90
-pengo2;90
-pengo2u;90
-pengo3u;90
-pengo4;90
-pengo5;90
-pengo6;90
-pengob;90
-pengojpm;90
-pengomc1;90
-pengomc2;90
-pengopac;90
-penta;90
-percuss;270
-perestro;90
-perestrof;90
-perfrman;270
-perfrmanu;270
-phelios;90
-pheliosj;90
-phoenix;90
-phoenix2;90
-phoenix3;90
-phoenixa;90
-phoenixass;90
-phoenixb;90
-phoenixc;90
-phoenixc2;90
-phoenixc3;90
-phoenixc4;90
-phoenixdal;90
-phoenixgu;90
-phoenixha;90
-phoenixi;90
-phoenixj;90
-phoenixs;90
-phoenixt;90
-phoenxp2;270
-phozon;90
-phozons;90
-pickin;270
-pignewt;270
-pignewta;270
-pinbo;90
-pinboa;90
-pinbos;90
-pinguinos;90
-pinkswts;270
-pinkswtsa;270
-pinkswtsb;270
-pinkswtssc;270
-pinkswtsx;270
-piranha;90
-piranhah;90
-piranhao;90
-piratesh;90
-pisces;90
-piscesb;90
-pitnrun;90
-pitnruna;90
-playball;270
-pleiadbl;90
-pleiadce;90
-pleiads;90
-pleiadsb2;90
-pleiadsi;90
-pleiadsn;90
-pleiadss;90
-plgirls;270
-plgirls2;270
-plgirls2b;270
-plusalph;270
-plygonet;90
-pntnpuzl;90
-polaris;270
-polarisa;270
-polarisb;270
-polarisbr;270
-polariso;270
-pollux;270
-polluxa;270
-polluxa2;270
-polluxn;270
-polynetw;90
-pootan;90
-pooyan;90
-pooyans;90
-popeyeman;90
-popflame;90
-popflamea;90
-popflameb;90
-popflamen;90
-popper;90
-porky;90
-porter;90
-portera;90
-portman;90
-portmanj;90
-portrait;270
-portraita;270
-poundfor;270
-poundforj;270
-poundforu;270
-ppking;90
-pprobe;90
-progolf;270
-progolfa;270
-progress;90
-prosoccr;270
-prosport;270
-prosporta;270
-protenn;270
-protennb;270
-prtytime;90
-psurge;270
-psychic5;270
-psychic5j;270
-psyvar2;270
-psyvaria;270
-psyvarij;270
-psyvarrv;270
-pturn;90
-puckman;90
-puckmanb;90
-puckmanf;90
-puckmanh;90
-puckmod;90
-pulsar;270
-puzlclub;90
-pwrflip;270
-pzlestar;270
-qbert;270
-qberta;270
-qbertj;270
-qbertqub;270
-qberttst;270
-qix;270
-qix2;270
-qixa;270
-qixb;270
-qixo;270
-quaak;90
-quantum;270
-quantum1;270
-quantump;270
-quarterb;270
-quarterba;270
-quarterbc;270
-quasar;90
-quasara;90
-quester;90
-questers;90
-qwak;270
-r2dtank;270
-r2dx_v33;270
-r2dx_v33_r2;270
-raaspec;270
-rackemup;90
-radarscp;270
-radarscp1;270
-radarscpc;270
-radarzon;90
-radarzon1;90
-radarzont;90
-radirgy;270
-radirgyo;270
-raflesia;270
-raflesiau;270
-raiden;270
-raiden2;270
-raiden2dx;270
-raiden2e;270
-raiden2ea;270
-raiden2eg;270
-raiden2es;270
-raiden2eu;270
-raiden2eua;270
-raiden2eub;270
-raiden2eup;270
-raiden2f;270
-raiden2g;270
-raiden2hk;270
-raiden2i;270
-raiden2j;270
-raiden2k;270
-raiden2nl;270
-raiden2sw;270
-raiden2u;270
-raidena;270
-raidenb;270
-raidendx;270
-raidendxa1;270
-raidendxa2;270
-raidendxch;270
-raidendxg;270
-raidendxj;270
-raidendxja;270
-raidendxk;270
-raidendxnl;270
-raidendxpt;270
-raidendxu;270
-raidenk;270
-raidenkb;270
-raidenm;270
-raident;270
-raidenu;270
-raidenua;270
-raidenub;270
-raiders;90
-raidersr3;90
-rallybik;270
-raphero;270
-rapheroa;270
-rayforce;90
-rayforcej;90
-razmataz;90
-rcorsair;90
-rdft;270
-rdft2;270
-rdft22kc;270
-rdft2a;270
-rdft2aa;270
-rdft2it;270
-rdft2j;270
-rdft2ja;270
-rdft2jb;270
-rdft2jc;270
-rdft2s;270
-rdft2t;270
-rdft2u;270
-rdft2us;270
-rdfta;270
-rdftadi;270
-rdftam;270
-rdftau;270
-rdftauge;270
-rdftgb;270
-rdftgr;270
-rdftit;270
-rdftj;270
-rdftja;270
-rdftjb;270
-rdfts;270
-rdftu;270
-rdftua;270
-reaktor;90
-redalert;270
-redclash;270
-redclashs;270
-redclasht;270
-redclashta;270
-redfoxwp2;270
-redfoxwp2a;270
-redhawk;270
-redhawke;270
-redhawkk;270
-redlin2p;270
-redrobin;90
-redufo;270
-redufob;90
-redufob2;90
-regulus;270
-reguluso;270
-regulusu;270
-repulse;90
-resclunar;270
-rescue;90
-rescueb;90
-rescuefe;90
-retofinv;90
-retofinvb;90
-retofinvb1;90
-retofinvb2;90
-retofinvb3;90
-revenger;270
-revngr84;270
-rfjet;270
-rfjet2kc;270
-rfjeta;270
-rfjetj;270
-rfjets;270
-rfjetsa;270
-rfjett;270
-rfjetu;270
-ridleofp;90
-ringfgt;90
-ringfgt2;90
-ringking;90
-ringking2;90
-ringking3;90
-ringkingw;90
-roadf;90
-roadf2;90
-roadfh;90
-roadfu;90
-rockduck;270
-rockn;270
-rockn2;270
-rockn3;270
-rockn4;270
-rockna;270
-rocknms;270
-rocnrope;270
-rocnropek;270
-rollace;270
-rollace2;90
-rollingc;270
-rompers;90
-romperso;90
-ropeman;270
-rotaryf;270
-roundup;90
-route16;270
-route16a;270
-route16b;270
-route16bl;270
-route16c;270
-route16d;270
-routex;270
-routexa;270
-rshark;270
-rugrats;90
-rumba;270
-runaways;270
-rundeep;270
-rushcrsh;270
-ryujin;270
-ryujina;270
-s1945;270
-s1945a;270
-s1945bl;270
-s1945ii;270
-s1945iii;270
-s1945j;270
-s1945k;270
-s1945n;270
-s1945nj;270
-safarir;90
-safarirj;90
-samesame;270
-samesame2;270
-samesamecn;270
-samesamenh;270
-samurai;270
-samuraia;270
-sandscrp;90
-sandscrpa;90
-sandscrpb;90
-sasuke;90
-satansat;90
-satansata;90
-satansatind;90
-saturnzi;270
-savanna;90
-savgbees;270
-sbagman;270
-sbagman2;270
-sbagmani;90
-sbagmans;270
-sbasketb;90
-sbaskete;90
-sbasketg;90
-sbasketh;90
-sbdk;270
-sbomber;270
-sbombera;270
-sbowling;90
-sbrkout;270
-sbrkout3;270
-sbrkoutc;270
-sbrkoutct;270
-sbugger;270
-sbuggera;270
-schaser;270
-schasera;270
-schaserb;270
-schaserc;270
-schasercv;270
-schaserm;270
-schick;90
-scobra;90
-scobrab;90
-scobrae;90
-scobrae2;90
-scobrag;90
-scobraggi;90
-scobras;90
-scobrase;90
-scontra;90
-scontraa;90
-scontraj;90
-scorpion;90
-scorpiona;90
-scorpionb;90
-scorpionmc;90
-scotrsht;90
-scramb2;90
-scramb3;90
-scramblb;90
-scramble;90
-scramblebb;90
-scramblebf;90
-scramblebun;90
-scrambleo;90
-scrambler;90
-scrambles;90
-scrambles2;90
-scrambp;90
-scramce;90
-scrammr;90
-scrampt;90
-scramrf;90
-scregg;270
-scyclone;270
-sdtennis;270
-sdungeon;270
-sdungeona;270
-searchar;90
-searcharj;90
-searcharu;90
-searthie;270
-searthin;270
-searthina;270
-sectrzon;90
-sectrzona;90
-sectrzont;90
-seicross;90
-seicrossa;90
-sengekis;90
-sengekisj;90
-senjyo;90
-sexyboom;270
-sfkick;90
-sfkicka;90
-sflush;270
-sformula;90
-sformulaa;90
-shaolinb;90
-shaolins;90
-sheriff;270
-shienryu;270
-shikgam2;270
-shikigam;270
-shikigama;270
-shippumd;270
-shollow;90
-shollow2;90
-shootbul;90
-shtrider;270
-shtridera;270
-shtriderb;270
-shuffle;90
-shuttlei;270
-sia2650;270
-sianniv;270
-sicv;270
-sicv1;270
-silvmil;270
-sindbadm;270
-sinistar;270
-sinistar2;270
-sinistarp;270
-sinvasn;270
-sinvasnb;270
-sinvemag;270
-sinvzen;270
-sirio2;90
-sisv;270
-sisv1;270
-sisv2;270
-sisv3;270
-sitv;270
-sitv1;270
-skullfng;270
-skullfnga;270
-skullfngj;270
-skyadvnt;90
-skyadvntj;90
-skyadvntu;90
-skyalert;270
-skyarmy;90
-skybase;90
-skybump;90
-skychut;270
-skyfox;90
-skylancr;90
-skylancre;90
-skylove;270
-skyraidr;90
-skyshark;270
-skysharka;270
-skysharkb;270
-skysmash;270
-skysoldr;90
-skysoldrbl;90
-sl2007;270
-slapfigh;270
-slapfigha;270
-slapfighb1;270
-slapfighb2;270
-slapfighb3;270
-slither;270
-slithera;270
-slspirit;270
-smooncrs;90
-sncwgltd;270
-sngkace;270
-sngkacea;270
-solarfox;270
-solarwar;270
-solfight;270
-sonicbom;270
-sonicbomd;270
-sonicwi;270
-sonofphx;90
-sos;90
-spacbat2;90
-spacbatt;90
-spacbeam;270
-spaceat2;270
-spaceatt;270
-spaceatt2k;270
-spaceattbp;270
-spacebrd;270
-spacecho;270
-spacecho2;270
-spacecom;270
-spacecr;90
-spacedem;270
-spacefb;270
-spacefba;270
-spacefbb;270
-spacefbe;270
-spacefbe2;270
-spacefbg;270
-spacefev;270
-spacefevo;270
-spacefevo2;270
-spacefrt;90
-spaceg;270
-spaceint;90
-spaceintj;90
-spacelnc;270
-spacempr;90
-spaceod;270
-spaceod2;270
-spaceph;270
-spaceplt;90
-spaceplta;90
-spacerng;90
-spacetrk;270
-spacetrkc;270
-spacewr3;270
-spacmiss;270
-spactrai;90
-spbactn;90
-spbactnj;90
-spbactnp;90
-spcdrag;90
-spceking;270
-spcewarl;270
-spcewarla;270
-spcewars;270
-spcforc2;270
-spcforce;270
-spcfrcii;270
-spcinv95;270
-spcinv95u;270
-spcking2;270
-spclaser;270
-spclforc;270
-spcmission;90
-spcpostn;90
-spctbird;90
-spctrek;90
-spcwarp;90
-spdcoin;90
-speakhlp;270
-speakres;270
-speakresb;270
-spec2k;270
-speedbal;270
-speedbala;270
-spfghmk2;90
-spfghmk22;90
-spiders;270
-spiders2;270
-spiders3;270
-spinkick;90
-spinner;270
-sprglbpg;90
-sprglobp;90
-springer;270
-sprpuzzle;270
-spyhunt;90
-spyhuntp;90
-spyhuntpr;90
-spyhuntsp;90
-sqbert;270
-sqix;90
-sqixb1;90
-sqixb2;90
-sqixr0;90
-sqixr1;90
-sqixu;90
-sraider;270
-srdarwin;270
-srdarwinj;270
-srdmissn;90
-srumbler;270
-srumbler2;270
-srumbler3;270
-ssi;270
-ssia;270
-ssib;270
-ssingles;90
-ssmissin;270
-ssozumo;270
-sspacaho;270
-sspaceat;270
-sspaceat2;270
-sspaceat3;270
-sspaceatc;270
-sspeedr;270
-sspiritj;270
-sspirits;270
-sspirtfc;270
-ssrj;90
-sstarbtl;90
-sstarcrs;90
-sstingry;90
-sstrangr;270
-sstrangr2;270
-sstriker;270
-sstrikerk;270
-stagger1;270
-starfght;90
-starfgmc;90
-starfigh;90
-starforc;90
-starforca;90
-starforcb;90
-starforce;90
-starjack;270
-starjacks;270
-starrkr;270
-starw;270
-starw1;270
-starwarr;270
-step3;270
-stepstag;270
-stera;90
-stg;270
-stinger;90
-stinger2;90
-stmblade;270
-stmbladej;270
-storming;270
-storminga;270
-stratab;270
-stratab1;270
-stratabs;270
-stratvox;270
-stratvoxa;270
-stratvoxb;270
-streakng;90
-streaknga;90
-strfbomb;90
-strfight;90
-strtheat;270
-strtheata;270
-stuntair;90
-sub;270
-sundance;270
-superabc;90
-superabco;90
-superbar;270
-superbik;90
-superbon;90
-superbug;270
-superg;90
-supergs;90
-supergx;90
-superinv;270
-superpac;90
-superpacm;90
-supertnk;90
-superwng;90
-superx;270
-superxm;270
-supinvsion;270
-supmodel;90
-suprglob;270
-suprheli;90
-suprmous;90
-suprridr;90
-survival;90
-swarm;90
-swat;270
-swipeout;270
-sxevious;90
-sxeviousj;90
-szaxxon;90
-tacscan;270
-tactcian;90
-tactcian2;90
-tagteam;270
-tail2nos;90
-takeoff;90
-talbot;270
-tankbatt;90
-tankbattb;90
-tankbust;90
-tattack;270
-taxidriv;90
-tazmani2;90
-tazmani3;90
-tazmania;90
-tazmaniet;90
-tazzmang;90
-tazzmang2;90
-tcobra2;270
-tcobra2u;270
-tdfever;90
-tdfever2;90
-tdfever2b;90
-tdfeverj;90
-tdragon;270
-tdragon1;270
-tdragon2;270
-tdragon2a;270
-tdragon3h;270
-tdragonb;270
-tdragonb2;270
-teamqb;270
-teamqb2;270
-teedoff;90
-telpacfl;270
-tempest;270
-tempest1;270
-tempest1r;270
-tempest2;270
-tempest3;270
-temptube;270
-tenkomor;90
-tenkomorja;90
-tenspot;270
-terracre;270
-terracrea;270
-terracren;270
-terracreo;270
-tharrier;270
-tharrierb;270
-tharrieru;270
-the26thz;90
-thedeep;270
-theend;90
-theends;90
-theendss;90
-theglob;270
-theglob2;270
-theglob3;270
-theglobme;90
-theglobp;90
-thehand;270
-thehole;90
-thehustl;90
-thehustlj;90
-thepit;90
-thepitj;90
-thepitm;90
-thepitu1;90
-thepitu2;90
-theportr;90
-thndblst;270
-thunderl;270
-thunderlbl;270
-thunderlbl2;270
-tigerh;270
-tigerhb1;270
-tigerhb2;270
-tigerhb3;270
-tigerhb4;270
-tigerhj;270
-timefgtr;90
-timelimt;90
-timeplt;90
-timeplta;90
-timepltc;90
-timescan;270
-timescan1;270
-timesold;90
-timesold1;90
-tinv2650;270
-tiptop;90
-tisland;270
-titanpac;90
-tndrcade;270
-tndrcadej;270
-tnextspc;90
-tnextspc2;90
-tnextspcj;90
-tnk3;270
-tnk3b;270
-tnk3j;270
-todruaga;90
-todruagao;90
-todruagas;90
-tokio;90
-tokiob;90
-tokioo;90
-tokiou;90
-tokisens;90
-tokisensa;90
-tomahawk;90
-tomahawk1;90
-toobin;270
-toobin1;270
-toobin2;270
-toobin2e;270
-toobine;270
-toobing;270
-topgunbl;90
-topgunr;90
-toprollr;90
-toratora;90
-toratorab;90
-torus;90
-toucheme;90
-touchemea;90
-tp84;90
-tp84a;90
-tp84b;90
-tranqgun;270
-travrusa;270
-travrusab;270
-travrusab2;270
-treahunt;90
-trgheart;270
-trghearto;270
-tricktrp;90
-trigon;90
-trikitri;90
-triplep;90
-triplepa;90
-tripool;90
-tripoola;90
-trisport;270
-trizeal;270
-tron;90
-tron2;90
-tron3;90
-tron4;90
-tron5;90
-tronger;90
-troopy;90
-truxton;270
-truxton2;270
-tryout;90
-tsamurai;90
-tsamurai2;90
-tsamuraih;90
-ttfitter;90
-tugboat;90
-tunhunt;90
-tunhuntc;90
-turbo;270
-turboa;270
-turbob;270
-turbobl;270
-turboc;270
-turbod;270
-turboe;270
-turbofrc;270
-turbofrco;270
-turbofrcu;270
-turbotag;90
-turpin;90
-turpins;90
-turtles;90
-tutankhm;90
-tutankhms;90
-twinbee;90
-twinbeeb;90
-twincobr;270
-twincobru;270
-twineag2;270
-twineagl;270
-twinhawk;270
-twinhawku;270
-typhoon;90
-ultrainv;270
-ultrax;270
-ultraxg;270
-unclepoo;90
-undefeat;270
-uniwars;90
-uniwarsa;90
-upndown;270
-upndownu;270
-upyoural;270
-usclssic;270
-valkyrie;90
-valtric;270
-vandyke;270
-vandykeb;270
-vandykejal;270
-vandykejal2;270
-vangrd2;270
-vanguard;90
-vanguardc;90
-vanguardg;90
-vanguardj;90
-vanvan;270
-vanvanb;270
-vanvank;270
-vaportra;270
-vaportra3;270
-vaportrau;270
-varth;270
-varthb;270
-varthb2;270
-varthj;270
-varthjr;270
-varthr1;270
-varthu;270
-vasara;270
-vasara2;270
-vasara2a;270
-vastar;90
-vastar2;90
-vastar3;90
-vastar4;90
-vautour;90
-vautourz;90
-vautourza;90
-vega;270
-venus;90
-verbena;270
-vfive;270
-victoryc;270
-victorycb;270
-victroad;270
-videight;90
-videopin;270
-videopina;270
-vimana;270
-vimanaj;270
-vimanan;270
-viprp1;270
-viprp1h;270
-viprp1hk;270
-viprp1j;270
-viprp1k;270
-viprp1oj;270
-viprp1ot;270
-viprp1pt;270
-viprp1s;270
-viprp1t;270
-viprp1u;270
-viprp1ua;270
-vmetal;90
-vmetaln;90
-volfied;270
-volfiedj;270
-volfiedjo;270
-volfiedo;270
-volfiedu;270
-volfieduo;270
-vortex;270
-vpool;90
-vsgongf;90
-vulgus;270
-vulgusa;90
-vulgusj;270
-waidrive;270
-wallst;90
-wanted;90
-wantsega;270
-warcadia;270
-warofbug;90
-warofbugg;90
-warofbugu;90
-warp1;270
-warp1bl;270
-warp1t;90
-warpwarp;90
-warpwarpr;90
-warpwarpr2;90
-waterski;270
-westgun2;270
-wiggie;270
-wingforc;270
-wiping;90
-wivernwg;270
-wiz;270
-wizt;270
-wizta;270
-wmatch;270
-wndrplnt;270
-woodpeca;90
-woodpeck;90
-worldwar;270
-wrestwar;270
-wrestwar1;270
-wrestwar1d;270
-wrestwar2;270
-wrestwar2d;270
-wrofaero;270
-wtennis;270
-ww3;270
-wwestern;270
-wwestern1;270
-wyvernf0;270
-wyvernf0a;270
-wyvernwg;270
-wyvernwga;270
-x2222;270
-x2222o;270
-xevios;90
-xevious;90
-xeviousa;90
-xeviousb;90
-xeviousc;90
-xiistag;270
-xxmissio;90
-yamagchi;90
-yamato;90
-yamato2;90
-yankeedo;270
-yellowcbb;270
-yellowcbj;270
-yosakdon;270
-yosakdona;270
-youjyudn;270
-youma;270
-youma2;270
-youmab;270
-youmab2;270
-yukiwo;270
-zaryavos;90
-zarzon;90
-zaviga;90
-zavigaj;90
-zaxxon;90
-zaxxon2;90
-zaxxon3;90
-zaxxonb;90
-zaxxonj;90
-zerohour;270
-zerohoura;270
-zerohouri;270
-zerotime;90
-zerotimed;90
-zerotimemc;90
-zerotimeu;90
-zigzagb;90
-zigzagb2;90
-zingzip;270
-zingzipbl;270
-zipzap;90
-zipzapa;90
-zoar;270
-zodiack;270
-zzyzzyxx;90
-zzyzzyxx2;90
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/naomi.csv b/package/batocera/core/batocera-configgen/data/special/naomi.csv
deleted file mode 100755
index d65c8e53183..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/naomi.csv
+++ /dev/null
@@ -1,25 +0,0 @@
-anmlbskta;270
-anmlbskt;270
-blokpong;270
-ikaruga;270
-illvelo;270
-karous;270
-mamonoro;270
-psyvar2;270
-quizqgd;270
-radirgy;270
-radirgyo;270
-shikgam2;270
-sl2007;270
-shorse;270
-starhrcl;270
-shors2k1;270
-shorsep;270
-starhrsp;270
-shorsepr;270
-starhrpr;270
-trghearto;270
-trgheart;270
-trizeal;270
-undefeat;270
-waidrive;270
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/nds.csv b/package/batocera/core/batocera-configgen/data/special/nds.csv
deleted file mode 100755
index b48a4d42afc..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/nds.csv
+++ /dev/null
@@ -1,157 +0,0 @@
-0356 - Korogashi Puzzle Katamari Damacy (Japan);90
-0396 - Brain Age - Train Your Brain in Minutes a Day! (USA);90
-0823 - Hotel Dusk - Room 215 (USA);90
-0831 - Wish Room - Tenshi no Kioku (Japan);90
-0841 - Delbo (USA);90
-0863 - Meteos - Disney Magic (USA);90
-0918 - Tokimeki Memorial Girl's Side - 1st Love (Japan);90
-1000 - Hotel Dusk - Room 215 (Europe) (En,Fr,De,Es,It);90
-1007 - Delbo (Europe, Australia);90
-100_Classic_Books_USA_NDS-BAHAMUT;90
-1034 - Meteos - Disney Magic (Japan);90
-1121 - Meteos - Disney Magic (Europe) (En,Fr,De,Es,It);90
-1129 - Planet Puzzle League (USA);90
-1182 - Puzzle League DS (Europe) (En,Fr,De,Es,It);90
-1343 - Brain Age 2 - More Training in Minutes a Day! (USA) (En,Fr,Es);90
-13_Sai_no_Hello_Work_DS_JPN_NDS-iND;90
-1873 - Left Brain, Right Brain - Use Both Hands, Train Both Sides (USA);90
-1880 - Hello Kitty no Oshare Party - Sanrio Character Zukan DS (Japan);90
-2032 - Tokimeki Memorial Girl's Side - 2nd Season (Japan);90
-2163 - Ninja Gaiden - Dragon Sword (Japan);90
-2189 - Ninja Gaiden - Dragon Sword (USA);90
-2331 - Shokugyou Taiken Adventure - 13-sai no Hello Work DS (Japan);90
-2380 - Guitar Hero - On Tour (USA) (En,Fr);90
-2387 - Ninja Gaiden - Dragon Sword (Europe) (En,Fr,De,Es,It);90
-2420 - Nanashi no Geemu (Japan);90
-2476 - Guitar Hero - On Tour (Europe) (Fr,De,Es,It);90
-2500 - Guitar Hero - On Tour (United Kingdom);90
-2517 - Rhythm Tengoku Gold (Japan);90
-2550 - Guitar Hero - On Tour (Korea) [b];90
-2581 - Sigma Harmonics (Japan);90
-2582 - Ninja Gaiden - Dragon Sword (Korea) [b];90
-2702 - My Secret World by Imagine (Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da);90
-2754 - Guitar Hero - On Tour (Japan);90
-2897 - Guitar Hero - On Tour - Decades (Europe) (Fr,De,Es,It);90
-2917 - My Dress-Up (Europe) (En,Fr,De,Es,It,Sv,No,Da,Fi);90
-2919 - My Secret Diary (Europe) (En,Fr,De,Es,It,Sv,No,Da,Fi);90
-2939 - Guitar Hero - On Tour - Decades (United Kingdom);90
-2939 - Guitar Hero - On Tour - Decades (United Kingdom);90
-2944 - Guitar Hero - On Tour - Decades (USA) (En,Fr);90
-3363 - My Secret World by Imagine (USA) (En,Fr,Es,Pt);90
-3380 - Hotel Duskui Bimil (Korea) [b];90
-3564 - Guitar Hero - On Tour - Decades (Korea) [b];90
-3588 - Rhythm Heaven (USA);90
-3601 - Akai Ito Destiny DS (Japan);90
-3699 - Rhythm Paradise (Europe) (En,Fr,De);90
-3724 - Tokimeki Memorial Girl's Side - 1st Love Plus (Japan) [b];90
-3822 - Rhythm Paradise (Europe) (En,Es,It);90
-3921 - Guitar Hero - On Tour - Modern Hits (Europe) (Fr,De,Es,It);90
-4039 - Gokujou!! Mecha Mote Iinchou - Girls 'Mote Kawa' Box (Japan);90
-4125 - My Dress-Up (USA);90
-4133 - Nanashi no Geemu Me (Japan);90
-4136 - Go! Go! Cosmo Cops! (Europe) (En,Fr,De,Es,It);90
-4152 - Love Plus (Japan);90
-4591 - Style Savvy (USA);90
-4654 - Last Window - Mayonaka no Yakusoku (Japan);90
-4876 - Again - Interactive Crime Novel (USA);90
-4930 - Guitar Hero - On Tour - Modern Hits (United Kingdom);90
-5014 - 100 Classic Books (USA);90
-5035 - Tokimeki Memorial Girl's Side - 3rd Story (Japan);90
-5171 - Brain Age - Train Your Brain in Minutes a Day! (USA) (Rev 1);90
-5212 - Last Window - The Secret of Cape West (Europe) (En,Fr,De,Es,It);90
-6390 - My Secret Diary (USA);90
-6470 - Winnie the Pooh - Kuma no Puu-san - 100 Acre no Mori no Cooking Book (Japan);90
-842 - Imagine - Fashion Designer - New York (USA) (En,Fr,Es,Pt);90
-907 - Guitar Hero - On Tour - Modern Hits (USA) (En,Fr);90
-Again_CRACKED_USA_NDS-SUXXORS;90
-Again_USA_NDS-BAHAMUT;90
-Akai_Ito_Destiny_DS_JPN_NDS-iND;90
-Brain_Age_2_Demo_USA_NDS-SirVG;90
-Brain_Age_2_USA_NDS-MR0;90
-Brain_Age_Train_Your_Brain_in_Minutes_a_Day_v1.01_USA_NDS-PLAYTHIS;90
-Brain_Age_USA_NDS-MODE7;90
-Brain_Age_USA_NDS-TRM;90
-Go_Go_Cosmo_Cops_EUR_MULTi5_NDS-BAHAMUT;90
-Go_Go_Cosmo_Cops_EUR_MULTi5_PROPER_NDS-iND;90
-Gokujou_Mechamote_Iinchou_Girls_Motekawa_Box_JPN_NDS-Caravan;90
-Guitar.Hero.On.Tour.KOR.NDS-CoolPoint;90
-Guitar_Hero_-_On_Tour_Decades_DEMO_USA_DIRFIX_NDS-jd;90
-Guitar_Hero_-_On_Tour_Decades_USA_NDS-jd;90
-GUITAR_HERO_DS_EUR_UK_NDS-DiPLODOCUS;90
-Guitar_Hero_On_Tour_Decades_DEMO_EUR_NDS-jd;90
-Guitar_Hero_On_Tour_Decades_DEMO_EUR_NDS-jd;90
-Guitar_Hero_On_Tour_Decades_EUR_MULTi4_CLEAN_NDS-COCKSLAP;90
-Guitar_Hero_On_Tour_Decades_EUR_MULTi4_NDS-EXiMiUS;90
-Guitar_Hero_On_Tour_Decades_EUR_READNFO_NDS-DiPLODOCUS;90
-Guitar_Hero_On_Tour_Decades_KOR_NDS-NEREiD;90
-Guitar_Hero_On_Tour_Decades_USA_NDS-GUARDiAN;90
-Guitar_Hero_On_Tour_DEMO_EUR_NDS-iND;90
-Guitar_Hero_On_Tour_DEMO_READNFO_EUR_NDS-iND;90
-Guitar_Hero_On_Tour_DEMO_USA_NDS-iND;90
-Guitar_Hero_On_Tour_Demo_Version_USA_iNTERNAL_NDS-jd;90
-Guitar_Hero_On_Tour_EUR_MULTi4_NDS-DSRP;90
-Guitar_Hero_On_Tour_EUR_MULTi4_NDS-iND;90
-Guitar_Hero_On_Tour_JPN_NDS-iND;90
-Guitar_Hero_On_Tour_Modern_Hits_EUR_MULTi4_NDS-BAHAMUT;90
-Guitar_Hero_On_Tour_Modern_Hits_READNFO_USA_DEMO_NDS-iND;90
-Guitar_Hero_On_Tour_Modern_Hits_USA_NDS-BAHAMUT;90
-Guitar_Hero_On_Tour_USA_READNFO_NDS-XPA;90
-Hello_Kitty_no_Oshare_Party_Sanrio_Character_Zukan_DS_JPN_NDS-6rz;90
-Hotel.Dusk.Room.KOR.NDS-CoolPoint;90
-Hotel_Dusk_Room_215_EUR_NDS-SUPREMACY;90
-Hotel_Dusk_Room_215_USA_INTERNAL_NDS-XPA;90
-Hotel_Dusk_Room_215_USA_NDS-WRG;90
-Imagine_Fashion_Designer_New_York_USA_NDS-XPA;90
-Last_Window_Mayonaka_no_Yakusoku_JPN_NDS-STORM;90
-Last_Window_The_Secret_of_Cape_West_EUR_CRACK_NDS-SUXXORS;90
-Last_Window_The_Secret_of_Cape_West_EUR_MULTi5_NDS-EXiMiUS;90
-Left_Brain_Right_Brain_USA_NDS-SirVG;90
-Love_Plus_JPN_NDS-iND;90
-Meteos_Disney_Magic_EUR_NDS;90
-Meteos_Disney_Magic_JPN_NDS-Caravan;90
-Meteos_Disney_Magic_USA_NDS-XPA;90
-My_Dress_Up_EUR_NDS-XPA;90
-My_Dress_Up_USA_NDS-BAHAMUT;90
-My_Secret_Diary_EUR_NDS-XPA;90
-My_Secret_Diary_USA_NDS-EXiMiUS;90
-My_Secret_World_by_Imagine_EUR_MULTI10_NDS-SQUiRE;90
-My_Secret_World_By_Imagine_USA_NDS-SirVG;90
-My_Secret_World_EUR_CLEAN_NDS-WetNWild;90
-Nanashi_no_Game_JPN_NDS-iND;90
-Nanashi_no_Game_JPN_NDS-iND;90
-Ninja.Gaiden.Dragon.Sword.KOR.NDS-Coolpoint;90
-Ninja_Gaiden_Dragon_Sword_EUR_MULTi5_NDS-EXiMiUS;90
-Ninja_Gaiden_Dragon_Sword_JPN_NDS-Caravan;90
-Ninja_Gaiden_Dragon_Sword_USA_NDS-XPA;90
-Planet_Puzzle_League_Demo_USA_NDS-SirVG;90
-Planet_Puzzle_League_USA_NDS-XPA;90
-Puzzle_League_DS_EUR_NDS-XPA;90
-Rhythm Heaven (Touchless);90
-Rhythm_Heaven_USA_NDS-XPA;90
-Rhythm_Paradise_DEMO_EUR_NDS-iND;90
-Rhythm_Paradise_EUR_MULTi3_NDS-EXiMiUS;90
-Rhythm_Paradise_EUR_MULTi3_READNFO_NDS-BAHAMUT;90
-Rhythm_Tengoku_Gold_JAP_NDS-CNBS;90
-Rhythm_Tengoku_Gold_JPN_NDS-DSRP;90
-Sigma_Harmonics_JPN_NDS-iND;90
-Sophies_Freunde_Meine_Geheimnisse_GER_NDS-DNB;90
-Stock Exchange Kabutore Next (Japan);90
-Style_Savvy_DEMO_USA_NDS-TDCC;90
-Style_Savvy_USA_NDS-XPA;90
-Winnie_The_Pooh_100_Acre_No_Mori_No_Cooking_Book_JAP_NDS-PUSSYCAT;90
-Wish_Room_Tenshi_no_Kioku_JPN_NDS-WRG;90
-x115 - Planet Puzzle League (USA) (Demo) (Kiosk);90
-x117 - Brain Age 2 - More Training in Minutes a Day! (USA) (Demo) (Kiosk);90
-x138 - Guitar Hero - On Tour (Europe) (Fr,De,Es,It) (Demo) (Kiosk);90
-x140 - Guitar Hero - On Tour - Decades (Europe) (Fr,De,Es,It) (Demo) (Kiosk);90
-x143 - Guitar Hero - On Tour - Decades (USA) (En,Fr) (Demo) (Kiosk);90
-x146 - Rhythm Paradise (Europe) (En,Fr,De) (Demo) (Kiosk);90
-x155 - Guitar Hero - On Tour (United Kingdom) (Demo) (Kiosk);90
-x158 - Guitar Hero - On Tour - Modern Hits (USA) (En,Fr) (Demo) (Kiosk);90
-x192 - Guitar Hero - On Tour (USA) (Demo) (Kiosk);90
-x196 - Style Savvy (USA) (Demo) (Kiosk);90
-z082 - Tokimeki Memorial Girl's Side - 1st Love (Japan) (Rev 1);90
-z119 - Tokimeki Memorial Girl's Side - 2nd Season (Japan) (Rev 1);90
-z170 - Rhythm Heaven (USA) (Demo) (Kiosk);90
-z175 - Ninja Gaiden - Dragon Sword (USA) (Demo) (Kiosk);90
-z189 - Puzzle League DS (Europe) (En,Fr,De,Es,It) (Rev 1);90
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/vectrex.csv b/package/batocera/core/batocera-configgen/data/special/vectrex.csv
deleted file mode 100755
index 3356a9643d8..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/vectrex.csv
+++ /dev/null
@@ -1,55 +0,0 @@
-2D Narrow Escape (Hombrew);2dnarrow
-3D Mine Storm (USA);3dminest
-AnimAction - Advanced Animation (USA);animactn
-Armor..Attack (World);armora
-Art Master (USA);artmstr
-Bedlam (USA, Europe);bedlam
-Berzerk (World);berzerk
-Blitz! - Action Football (USA, Europe) (0F11CE0C);blitz
-Blitz! - Action Football (USA, Europe) (881B27C5);blitz
-Blitz! - Action Football (USA, Europe);blitz
-Clean Sweep (World);cleanswp
-Cosmic Chasm (World);cchasm
-Dark Tower (USA) (Proto);darktowr
-Dark Tower (World) (Proto);darktowr
-Engine Analyzer (USA) (Proto);enginean
-Flipper Pinball (World);fpinball
-Fortress of Narzod (USA, Europe);fnarzod
-Heads-Up - Action Soccer (USA);headsup
-HyperChase - Auto Race (World);hyprchas
-Mail Plane (USA) (Proto) (05838962);mailplan
-Mail Plane (USA) (Proto) (DA1AC0DB);mailplan
-Melody Master - Music Composition and Entertainment (USA);melody
-Mine Storm (World);minestrm
-Mine Storm II (USA) (Rev 2);minestr2
-Mine Storm II (USA);minestr2
-Mr. Boston Clean Sweep (Europe);mrboston
-Patriots (Homebrew);patriots
-Patriots3 (Homebrew);patriot3
-Pitcher's Duel (USA) (Proto);ptchduel
-Polar Rescue (USA) (Beta);polarres
-Polar Rescue (USA);polarres
-Pole Position (USA) (A00ED3D6);polepos
-Pole Position (USA) (C10F37D8);polepos
-Pole Position (USA);polepos
-Rip Off (World);ripoff
-Scramble (USA, Europe);scramble
-Sledge 3D (USA) (Sample);rocksled
-Solar Quest (World);solarq
-Space Wars (World);spacewar
-Spike (USA, Europe);spike
-Spike Goes Skiing (Homebrew);spikeski
-Spin ball (USA);spinball
-Spinball (USA);spinball
-Star Castle (USA);starcas
-Star Ship (Europe);starship
-Star Trek - The Motion Picture (USA);startrek
-StarHawk (World);starhawk
-Test Cartridge (USA) (Rev 4) (Proto);testrev4
-Tour De France (USA) (Proto);tourfrnc
-Vectrace (Homebrew);vectrace
-Vectrex Frogger (Homebrew);frogger
-Vectrexians (Homebrew);vectrian
-WebWarp (Europe);webwarp
-WebWars (USA);webwars
-Wormhole (Homebrew);wormhole
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/wswan.csv b/package/batocera/core/batocera-configgen/data/special/wswan.csv
deleted file mode 100755
index b5b543eb137..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/wswan.csv
+++ /dev/null
@@ -1,21 +0,0 @@
-beat mania for WonderSwan (Japan);90
-Beatmania for WonderSwan (Japan);90
-Crazy Climber (Japan);90
-D's Garage 21 Koubo Game - Tane o Maku Tori (Japan);90
-From TV Animation One Piece - Mezase Kaizoku Ou! (Japan);90
-GunPey (Japan);90
-Magical Drop for WonderSwan (Japan);90
-Meitantei Conan - Majutsushi no Chousenjou! (Japan);90
-Mingle Magnet (Japan) (En,Ja) (Rev 1);90
-Mingle Magnet (Japan);90
-Mobile Suit Gundam MSVS (Japan);90
-Puzzle Bobble (Japan);90
-Rainbow Islands - Putty's Party (Japan);90
-Ring Infinity (Japan);90
-Sennou Millennium (Japan);90
-Slither Link (Japan);90
-Tare Panda no GunPey (Japan);90
-Terrors (Japan);90
-Tetsujin 28 Gou (Japan);90
-Tokyo Majin Gakuen - Fuju Houroku (Japan);90
-Uzumaki - Denshi Kaiki Hen (Japan) (Rev 4);90
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/data/special/wswanc.csv b/package/batocera/core/batocera-configgen/data/special/wswanc.csv
deleted file mode 100755
index d1ce29b268d..00000000000
--- a/package/batocera/core/batocera-configgen/data/special/wswanc.csv
+++ /dev/null
@@ -1,14 +0,0 @@
-Another Heaven - Memory of those Days (Japan);90
-Digital Monster Card Game - Ver. WonderSwan Color (Japan);90
-Digital Monster Card Game - Ver. WonderSwan Color (Japan) (Rev 2);90
-Digital Monster Card Game - Ver. WonderSwan Color (Japan) [T-En by Kaiisen & Wazzpinator v1.1] [i];90
-GunPey EX (Japan);90
-Judgement Silversword - Rebirth Edition (Japan) (Rev 4321);90
-Judgement Silversword - Rebirth Edition (Japan) (Rev 5C21);90
-Kurupara! (Japan) (Rev 1);90
-Last Alive (Japan);90
-Mikeneko Holmes - Ghost Panic (Japan);90
-Sorobang (Japan) (Rev 1);90
-Terrors 2 (Japan);90
-Tetris (Japan);90
-Wonder Classic (Japan);90
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/scripts/batocera-joysticks-hotkeys.py b/package/batocera/core/batocera-configgen/scripts/batocera-joysticks-hotkeys.py
deleted file mode 100755
index 3fa41f2c06c..00000000000
--- a/package/batocera/core/batocera-configgen/scripts/batocera-joysticks-hotkeys.py
+++ /dev/null
@@ -1,260 +0,0 @@
-#!/usr/bin/env python3
-
-import argparse
-import sys
-import json
-from pathlib import Path
-
-SYSTEM_HOTKEYS_FILE = Path("/usr/share/evmapy/hotkeys.keys")
-USER_HOTKEYS_FILE = Path("/userdata/system/configs/hotkeys.keys")
-HOTKEYGEN_MAPPING = Path("/etc/hotkeygen/default_mapping.conf")
-
-def read_config(system_config_file: Path, user_config_file: Path, systemOnly: bool = False):
- if user_config_file.exists() and systemOnly == False:
- try:
- config = json.loads(user_config_file.read_text())
- return config
- except:
- print("Unable to read user file {}".format(user_config_file), file=sys.stderr)
-
- config = json.loads(system_config_file.read_text())
- return config
-
-def read_hotkey_mapping(hotkey_mapping_file: Path):
- mapping = json.loads(hotkey_mapping_file.read_text())
- by_keys = mapping
-
- non_game_hotkeys = ["KEY_FILE"]
-
- # remove hotkeys not for games
- for k in non_game_hotkeys:
- if k in by_keys:
- del by_keys["KEY_FILE"]
-
- by_names = {}
- for m in by_keys:
- by_names[by_keys[m]] = m
- return { "by_keys": by_keys, "by_names": by_names}
-
-def list_hotkeys(config: dict, default_config: dict, hotkeys_mapping: dict):
- if sys.stdout.isatty():
- list_hotkeys_text(config, default_config, hotkeys_mapping)
- else:
- list_hotkeys_xml(config, default_config, hotkeys_mapping)
-
-def isSimpleKey(key):
- return isinstance(key["trigger"], list) and len(key["trigger"]) == 2 and key["trigger"][0] == "hotkey" and key["type"] == "key" and isinstance(key["target"], list) and len(key["target"]) == 1
-
-def getKeysFromConfig(config):
- keys = {}
- for key in config["actions_player1"]:
- if isSimpleKey(key):
- btn = key["trigger"][1]
- action = key["target"][0]
- keys[btn] = {"action": action}
- return keys
-
-def list_hotkeys_xml(config: dict, default_config: dict, hotkeys_mapping: dict):
- keys = getKeysFromConfig(config)
- default_keys = getKeysFromConfig(default_config)
- order = ["start", "select", "up", "down", "left", "right", "a", "b", "x", "y", "pageup", "pagedown", "l2", "r2", "l3", "r3"]
-
- sorted_keys = {}
- for k in order:
- if k in keys:
- sorted_keys[k] = keys[k]
- else:
- sorted_keys[k] = None
- for k in keys:
- if k not in sorted_keys:
- sorted_keys[k] = keys[k]
-
- print("")
- for btn in sorted_keys:
- action_name = ""
- default_action_name = ""
-
- if btn in keys:
- action_name = "unknown ({})".format(keys[btn]["action"])
- if keys[btn]["action"] in hotkeys_mapping["by_keys"]:
- action_name = hotkeys_mapping["by_keys"][keys[btn]["action"]]
-
- if btn in default_keys:
- default_action_name = "unknown ({})".format(default_keys[btn]["action"])
- if default_keys[btn]["action"] in hotkeys_mapping["by_keys"]:
- default_action_name = hotkeys_mapping["by_keys"][default_keys[btn]["action"]]
-
- print(" ".format(btn, action_name, default_action_name))
-
- print("")
-
-def list_hotkeys_text(config: dict, default_config: dict, hotkeys_mapping: dict):
- keys = getKeysFromConfig(config)
- default_keys = getKeysFromConfig(default_config)
- order = ["start", "select", "up", "down", "left", "right", "a", "b", "x", "y", "pageup", "pagedown", "l2", "r2", "l3", "r3"]
-
- sorted_keys = {}
- for k in order:
- if k in keys:
- sorted_keys[k] = keys[k]
- else:
- sorted_keys[k] = None
- for k in keys:
- if k not in sorted_keys:
- sorted_keys[k] = keys[k]
-
- for btn in sorted_keys:
- action_name = None
- default_action_name = None
-
- if btn in keys:
- action_name = "unknown ({})".format(keys[btn]["action"])
- if keys[btn]["action"] in hotkeys_mapping["by_keys"]:
- action_name = hotkeys_mapping["by_keys"][keys[btn]["action"]]
-
- if btn in default_keys:
- default_action_name = "unknown ({})".format(default_keys[btn]["action"])
- if default_keys[btn]["action"] in hotkeys_mapping["by_keys"]:
- default_action_name = hotkeys_mapping["by_keys"][default_keys[btn]["action"]]
-
- if btn == "pageup":
- btn = "l"
- if btn == "pagedown":
- btn = "r"
-
- help = ""
- if action_name != default_action_name:
- help = " (default: {})".format(default_action_name)
- print("hotkey + {:6} : {}{}".format(btn, action_name, help))
-
-def update_hotkeys(config: dict, new_keys: dict, user_config_file: Path, default_config: dict):
- # update keys
- for new_key in new_keys:
- if gdebug:
- print(f"updating key {new_key}", file=sys.stderr)
- found = False
- for index, key in enumerate(config["actions_player1"]):
- if isSimpleKey(key):
- if key["trigger"][1] == new_key:
- if gdebug:
- print(f"key {new_key} already set. reaffecting it.", file=sys.stderr)
- found = True
- if new_keys[new_key] == "none":
- del config["actions_player1"][index]
- else:
- if new_keys[new_key] == "default":
- # get default
- for default_key in default_config["actions_player1"]:
- if isSimpleKey(default_key):
- if default_key["trigger"][1] == new_key:
- config["actions_player1"][index]["target"] = default_key["target"]
- else:
- config["actions_player1"][index]["target"] = [ new_keys[new_key] ]
- # the key was removed, add it back from default config
- if not found:
- if gdebug:
- print(f"key {new_key} is not defined. affecting it.", file=sys.stderr)
- if new_keys[new_key] != "none":
- found = False
- for key in default_config["actions_player1"]:
- if isSimpleKey(key):
- if key["trigger"][1] == new_key:
- # find the default key and append it
- found = True
- print(f"key {new_key} initialized with default value, before reaffecting.", file=sys.stderr)
- if new_keys[new_key] != "default":
- key["target"] = [ new_keys[new_key] ]
- config["actions_player1"].append(key)
- if not found and new_keys[new_key] != "default":
- print(f"key {new_key} has no default value. affect a default mapping.", file=sys.stderr)
- key = { "trigger": [ "hotkey", new_key ], "type": "key", "target": [ new_keys[new_key] ] }
- config["actions_player1"].append(key)
- # save
- with open(user_config_file, "w") as fd:
- json.dump(config, fd, indent=4)
-
-def list_values(hotkeys_mapping):
- for key in sorted(hotkeys_mapping["by_names"]):
- print(key)
-
-parser = argparse.ArgumentParser(prog="batocera-joysticks-hotkeys")
-parser.add_argument("--values", action="store_true", help="list possible values. none and default can be used too.")
-parser.add_argument("--start", type=str, help="key for hotkey+start")
-parser.add_argument("--select", type=str, help="key for hotkey+select")
-parser.add_argument("--up", type=str, help="key for hotkey+up")
-parser.add_argument("--down", type=str, help="key for hotkey+down")
-parser.add_argument("--left", type=str, help="key for hotkey+left")
-parser.add_argument("--right", type=str, help="key for hotkey+right")
-parser.add_argument("--a", type=str, help="key for hotkey+a")
-parser.add_argument("--b", type=str, help="key for hotkey+b")
-parser.add_argument("--x", type=str, help="key for hotkey+x")
-parser.add_argument("--y", type=str, help="key for hotkey+y")
-parser.add_argument("--l", type=str, help="key for hotkey+l")
-parser.add_argument("--r", type=str, help="key for hotkey+r")
-parser.add_argument("--pageup", type=str, help="key for hotkey+l")
-parser.add_argument("--pagedown", type=str, help="key for hotkey+r")
-parser.add_argument("--l2", type=str, help="key for hotkey+l2")
-parser.add_argument("--r2", type=str, help="key for hotkey+r2")
-parser.add_argument("--l3", type=str, help="key for hotkey+l3")
-parser.add_argument("--r3", type=str, help="key for hotkey+r3")
-parser.add_argument("--debug", action="store_true")
-
-args = parser.parse_args()
-new_keys = {}
-
-if args.start:
- new_keys["start"] = args.start
-if args.select:
- new_keys["select"] = args.select
-if args.up:
- new_keys["up"] = args.up
-if args.down:
- new_keys["down"] = args.down
-if args.left:
- new_keys["left"] = args.left
-if args.right:
- new_keys["right"] = args.right
-if args.a:
- new_keys["a"] = args.a
-if args.b:
- new_keys["b"] = args.b
-if args.x:
- new_keys["x"] = args.x
-if args.y:
- new_keys["y"] = args.y
-if args.l:
- new_keys["pageup"] = args.l
-if args.r:
- new_keys["pagedown"] = args.r
-if args.pageup:
- new_keys["pageup"] = args.pageup
-if args.pagedown:
- new_keys["pagedown"] = args.pagedown
-if args.l2:
- new_keys["l2"] = args.l2
-if args.r2:
- new_keys["r2"] = args.r2
-if args.l3:
- new_keys["l3"] = args.l3
-if args.r3:
- new_keys["r3"] = args.r3
-
-gdebug = args.debug
-hotkeys_mapping = read_hotkey_mapping(HOTKEYGEN_MAPPING)
-
-if args.values:
- list_values(hotkeys_mapping)
- exit(0)
-
-default_config = read_config(SYSTEM_HOTKEYS_FILE, USER_HOTKEYS_FILE, True)
-config = read_config(SYSTEM_HOTKEYS_FILE, USER_HOTKEYS_FILE)
-
-# convert new keys
-for k in new_keys:
- if new_keys[k] in hotkeys_mapping["by_names"]:
- new_keys[k] = hotkeys_mapping["by_names"][new_keys[k]]
-
-if len(new_keys) == 0:
- list_hotkeys(config, default_config, hotkeys_mapping)
-else:
- update_hotkeys(config, new_keys, USER_HOTKEYS_FILE, default_config)
diff --git a/package/batocera/core/batocera-controlcenter/batocera-controlcenter-play-time.sh b/package/batocera/core/batocera-controlcenter/batocera-controlcenter-play-time.sh
new file mode 100644
index 00000000000..990cb5a14af
--- /dev/null
+++ b/package/batocera/core/batocera-controlcenter/batocera-controlcenter-play-time.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+# A helper for batocera-controlcenter to get the running time of the batocera-launch process
+
+PID="$(pgrep -x -n batocera-launch)"
+
+if [[ -n "$PID" ]]; then
+ PLAYING_TIME="$(ps -o etime= -p "$PID" | tr -d '[:blank:]')"
+
+ if [[ -n "$PLAYING_TIME" ]]; then
+ echo "Playing for $PLAYING_TIME"
+ fi
+fi
diff --git a/package/batocera/core/batocera-controlcenter/batocera-controlcenter.mk b/package/batocera/core/batocera-controlcenter/batocera-controlcenter.mk
index 6fcc78aaab7..ed633a7d103 100644
--- a/package/batocera/core/batocera-controlcenter/batocera-controlcenter.mk
+++ b/package/batocera/core/batocera-controlcenter/batocera-controlcenter.mk
@@ -28,22 +28,24 @@ define BATOCERA_CONTROLCENTER_INSTALL_TARGET_CMDS
mkdir -p $(TARGET_DIR)/usr/bin
mkdir -p $(TARGET_DIR)/usr/share/batocera/controlcenter
mkdir -p $(TARGET_DIR)/usr/share/batocera/controlcenter/resources
- install -m 0755 $(@D)/controlcenter.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/style.css $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/ui_core.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/xml_utils.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/shell.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/refresh.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/gamepads.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/DocViewer.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(@D)/log.py $(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(BATOCERA_CONTROLCENTER_PATH)/images/bato-record.gif \
+ $(INSTALL) -m 0755 $(@D)/controlcenter.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/style.css $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/ui_core.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/xml_utils.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/shell.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/refresh.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/gamepads.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/DocViewer.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(@D)/log.py $(TARGET_DIR)/usr/share/batocera/controlcenter
+ $(INSTALL) -m 0755 $(BATOCERA_CONTROLCENTER_PATH)/images/bato-record.gif \
$(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0755 $(BATOCERA_CONTROLCENTER_PATH)/batocera-controlcenter-toogle.sh \
+ $(INSTALL) -m 0755 $(BATOCERA_CONTROLCENTER_PATH)/batocera-controlcenter-toogle.sh \
$(TARGET_DIR)/usr/bin/batocera-controlcenter
- install -m 0755 $(BATOCERA_CONTROLCENTER_PATH)/controlcenter.xml \
+ $(INSTALL) -m 0755 $(BATOCERA_CONTROLCENTER_PATH)/batocera-controlcenter-play-time.sh \
+ $(TARGET_DIR)/usr/bin/batocera-controlcenter-play-time
+ $(INSTALL) -m 0755 $(BATOCERA_CONTROLCENTER_PATH)/controlcenter.xml \
$(TARGET_DIR)/usr/share/batocera/controlcenter
- install -m 0644 $(BATOCERA_CONTROLCENTER_PATH)/ra-icon.png \
+ $(INSTALL) -m 0644 $(BATOCERA_CONTROLCENTER_PATH)/ra-icon.png \
$(TARGET_DIR)/usr/share/batocera/controlcenter/resources/ra-icon.png
cd $(TARGET_DIR)/usr/bin; ln -sf ../share/batocera/controlcenter/controlcenter.py \
./batocera-controlcenter-app
diff --git a/package/batocera/core/batocera-controlcenter/controlcenter.xml b/package/batocera/core/batocera-controlcenter/controlcenter.xml
index dc10f932be2..b0cca3e4c84 100644
--- a/package/batocera/core/batocera-controlcenter/controlcenter.xml
+++ b/package/batocera/core/batocera-controlcenter/controlcenter.xml
@@ -72,7 +72,7 @@
-
+
diff --git a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-cemu b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-cemu
index 5897d54fa15..ec3c37d5ae6 100755
--- a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-cemu
+++ b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-cemu
@@ -5,6 +5,6 @@ then
export DISPLAY=$(getLocalXDisplay)
fi
-emulatorlauncher -system wiiu -rom config
+batocera-launch -system wiiu -rom config
exit 0
diff --git a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-model2emu b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-model2emu
index f8095ec7926..f2c3c24a67b 100755
--- a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-model2emu
+++ b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-model2emu
@@ -7,6 +7,6 @@ else
export DISPLAY=$(getLocalXDisplay)
fi
-emulatorlauncher -system model2 -rom config
+batocera-launch -system model2 -rom config
exit 0
diff --git a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-play b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-play
index 25eba87708a..983f87e0481 100755
--- a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-play
+++ b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-play
@@ -5,6 +5,6 @@ then
export DISPLAY=$(getLocalXDisplay)
fi
-emulatorlauncher -system namco2x6 -rom config
+batocera-launch -system namco2x6 -rom config
exit 0
diff --git a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-rpcs3 b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-rpcs3
index 195397176fa..6005f0846ae 100755
--- a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-rpcs3
+++ b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-rpcs3
@@ -8,4 +8,4 @@ then
export DISPLAY=$(getLocalXDisplay)
fi
-emulatorlauncher -system ps3 -rom config -emulator rpcs3
+batocera-launch -system ps3 -rom config -emulator rpcs3
diff --git a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-shadps4 b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-shadps4
index 071730aa7d5..7a09e71b1a6 100755
--- a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-shadps4
+++ b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-shadps4
@@ -30,4 +30,4 @@ if [ ! -d "$dlcPath" ]; then
fi
fi
-emulatorlauncher -system ps4 -rom config -emulator shadps4
+batocera-launch -system ps4 -rom config -emulator shadps4
diff --git a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia
index dbd9f2319bc..1e7ab02c8dd 100755
--- a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia
+++ b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia
@@ -5,6 +5,6 @@ then
export DISPLAY=$(getLocalXDisplay)
fi
-emulatorlauncher -system xbox360 -emulator xenia -core xenia -rom config
+batocera-launch -system xbox360 -emulator xenia -core xenia -rom config
exit 0
diff --git a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia-canary b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia-canary
index a6f4fac1161..6a5a73bcbd4 100755
--- a/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia-canary
+++ b/package/batocera/core/batocera-desktopapps/scripts/batocera-config-xenia-canary
@@ -5,6 +5,6 @@ then
export DISPLAY=$(getLocalXDisplay)
fi
-emulatorlauncher -system xbox360 -emulator xenia-canary -core xenia-canary -rom config
+batocera-launch -system xbox360 -emulator xenia-canary -core xenia-canary -rom config
exit 0
diff --git a/package/batocera/core/batocera-desktopapps/scripts/view-eslog b/package/batocera/core/batocera-desktopapps/scripts/view-eslog
index 496f99f190b..bcb4c243fe9 100644
--- a/package/batocera/core/batocera-desktopapps/scripts/view-eslog
+++ b/package/batocera/core/batocera-desktopapps/scripts/view-eslog
@@ -28,6 +28,8 @@ i=/userdata/system/user-logs.txt
LOGS+=(
/userdata/system/logs/es_launch_stdout.log
/userdata/system/logs/es_launch_stderr.log
+ /userdata/system/logs/batocera-launch.log
+ /userdata/system/logs/batocera-launch-emulator.log
/userdata/system/logs/display.log
/userdata/system/logs/language.log
/userdata/system/batocera.conf
diff --git a/package/batocera/core/batocera-scripts/scripts/batocera-es-swissknife b/package/batocera/core/batocera-scripts/scripts/batocera-es-swissknife
index e59b95dc504..465ff376dc0 100644
--- a/package/batocera/core/batocera-scripts/scripts/batocera-es-swissknife
+++ b/package/batocera/core/batocera-scripts/scripts/batocera-es-swissknife
@@ -22,7 +22,7 @@ local cpids="$(pgrep -P $1)"
# Emulator currently running?
function check_emurun() {
- echo $(pgrep -f -n emulatorlauncher || echo 0)
+ echo $(pgrep -x -n batocera-launch || echo 0)
}
# Emulationstation currently running?
diff --git a/package/batocera/core/batocera-scripts/scripts/batocera-kodi b/package/batocera/core/batocera-scripts/scripts/batocera-kodi
index bcb06bb8faf..7b3f5348a6c 100755
--- a/package/batocera/core/batocera-scripts/scripts/batocera-kodi
+++ b/package/batocera/core/batocera-scripts/scripts/batocera-kodi
@@ -1,5 +1,5 @@
#!/bin/sh
-# script to be called from es to call configgen + kodi
+# script to be called from es to call batocera-launch + kodi
sleep 5
-emulatorlauncher -system kodi -rom '' "$@"
+batocera-launch -system kodi -rom '' "$@"
diff --git a/package/batocera/core/batocera-system/Config.in b/package/batocera/core/batocera-system/Config.in
index 4bd44bbf90e..39e53762fe9 100644
--- a/package/batocera/core/batocera-system/Config.in
+++ b/package/batocera/core/batocera-system/Config.in
@@ -173,6 +173,7 @@ config BR2_PACKAGE_BATOCERA_SYSTEM
select BR2_PACKAGE_PYTHON_BATOCERA_COMMON # common python functionality
select BR2_PACKAGE_BATOCERA_ES_SYSTEM # emulation station es_systems.cfg generator + default roms
select BR2_PACKAGE_BATOCERA_CONFIGGEN # emulation station external command launcher
+ select BR2_PACKAGE_BATOCERA_LAUNCH # new emulation station external command launcher
select BR2_PACKAGE_BATOCERA_TRIGGERHAPPY # multimedia/power buttons
select BR2_PACKAGE_BATOCERA_UDEV_RULES # extra udev rules for specifics joysticks/devices
select BR2_PACKAGE_BATOCERA_USERDATAINIT # userdata init files
@@ -1996,6 +1997,7 @@ config BR2_PACKAGE_BATOCERA_KODI21
bool "batocera.linux Kodi 21 packages selection"
default n
select BR2_PACKAGE_KODI # kodi
+ select BR2_PACKAGE_BATOCERA_LAUNCH_KODI
select BR2_PACKAGE_PYTHON_PYOPENSSL # used by some kodi plugins
select BR2_PACKAGE_X264 # decoder
select BR2_PACKAGE_X265 # decoder
diff --git a/package/batocera/emulationstation/batocera-es-system/batocera-es-system.mk b/package/batocera/emulationstation/batocera-es-system/batocera-es-system.mk
index 6f77e7b6c97..124a77710e4 100644
--- a/package/batocera/emulationstation/batocera-es-system/batocera-es-system.mk
+++ b/package/batocera/emulationstation/batocera-es-system/batocera-es-system.mk
@@ -7,7 +7,7 @@
BATOCERA_ES_SYSTEM_SOURCE=
BATOCERA_ES_SYSTEM_OVERRIDE_SRCDIR=
BATOCERA_ES_SYSTEM_INSTALL_STAGING = YES
-BATOCERA_ES_SYSTEM_DEPENDENCIES = host-batocera-es-system batocera-configgen host-gettext
+BATOCERA_ES_SYSTEM_DEPENDENCIES = host-batocera-es-system batocera-launch host-gettext
HOST_BATOCERA_ES_SYSTEM_OVERRIDE_SRCDIR=$(BR2_EXTERNAL_BATOCERA_PATH)/python-src/batocera-es-system
HOST_BATOCERA_ES_SYSTEM_OVERRIDE_SRCDIR_RSYNC_EXCLUSIONS=--exclude=".*" --exclude="**/__pycache__/" --exclude="dist"
@@ -33,7 +33,7 @@ define BATOCERA_ES_SYSTEM_BUILD_CMDS
--es-systems-yml=$(BATOCERA_ES_SYSTEM_PKGDIR)/es_systems.yml \
--locales-dir=$(BATOCERA_ES_SYSTEM_PKGDIR)/locales \
--roms-dir=$(BATOCERA_ES_SYSTEM_PKGDIR)/roms \
- --configgen=$(STAGING_DIR)/usr/share/batocera/configgen \
+ --defaults=$(STAGING_DIR)/usr/share/batocera/launch/defaults \
--keys-dir=$(BR2_EXTERNAL_BATOCERA_PATH)/package/batocera \
--output=$(BATOCERA_ES_SYSTEM_DIR) \
--arch=$(BATOCERA_ARCH) \
diff --git a/package/batocera/emulators/drastic/Config.in b/package/batocera/emulators/drastic/Config.in
index 0ae2e1c9241..fac6e3d081e 100644
--- a/package/batocera/emulators/drastic/Config.in
+++ b/package/batocera/emulators/drastic/Config.in
@@ -1,10 +1,11 @@
config BR2_PACKAGE_DRASTIC
bool "drastic"
- select BR2_PACKAGE_SDL2
+ select BR2_PACKAGE_SDL2
+ select BR2_PACKAGE_BATOCERA_LAUNCH_DRASTIC
help
- Drastic is a Nintendo DS emulator.
- This is an Aarch64 specific binary.
+ Drastic is a Nintendo DS emulator.
+ This is an Aarch64 specific binary.
- libdrastouch.c courtesy of Rocknix development.
-
- https://www.drastic-ds.com/
+ libdrastouch.c courtesy of Rocknix development.
+
+ https://www.drastic-ds.com/
diff --git a/package/batocera/emulators/flycast/Config.in b/package/batocera/emulators/flycast/Config.in
index 5bc011653f0..60890c8da87 100644
--- a/package/batocera/emulators/flycast/Config.in
+++ b/package/batocera/emulators/flycast/Config.in
@@ -11,5 +11,6 @@ config BR2_PACKAGE_FLYCAST
select BR2_PACKAGE_LIBEVDEV
select BR2_PACKAGE_LIBMINIUPNPC
select BR2_PACKAGE_ELFUTILS
+ select BR2_PACKAGE_BATOCERA_LAUNCH_FLYCAST
help
Flycast is a multi-platform Sega Dreamcast, Naomi and Atomiswave emulator.
diff --git a/package/batocera/emulators/mame/mame.mk b/package/batocera/emulators/mame/mame.mk
index 3478abd5cb7..b3d6177b5a2 100644
--- a/package/batocera/emulators/mame/mame.mk
+++ b/package/batocera/emulators/mame/mame.mk
@@ -7,7 +7,7 @@
MAME_VERSION = gm0289sr222f
MAME_SITE = $(call github,antonioginer,GroovyMAME,$(MAME_VERSION))
MAME_DEPENDENCIES += alsa-lib expat flac fontconfig glm jpeg libpng lua
-MAME_DEPENDENCIES += pulseaudio rapidjson sdl2 sdl2_ttf sqlite zlib zstd
+MAME_DEPENDENCIES += pulseaudio rapidjson sdl2 sdl2_ttf sqlite zlib zstd
$(eval $(call register,mame.emulator.yml))
$(eval $(call register-if-kconfig,BR2_PACKAGE_BATOCERA_VULKAN,bgfxbackend.mame.emulator.yml))
@@ -263,9 +263,9 @@ define MAME_INSTALL_TARGET_CMDS
# gameStop script when exiting a rotated screen (xorg)
if [ "$(BR2_PACKAGE_XSERVER_XORG_SERVER)" = "y" ]; then \
- mkdir -p $(TARGET_DIR)/usr/share/batocera/configgen/scripts; \
+ mkdir -p $(TARGET_DIR)/usr/share/batocera/launch/scripts; \
cp $(BR2_EXTERNAL_BATOCERA_PATH)/package/batocera/emulators/mame/rotation_fix.sh \
- $(TARGET_DIR)/usr/share/batocera/configgen/scripts/rotation_fix.sh; \
+ $(TARGET_DIR)/usr/share/batocera/launch/scripts/rotation_fix.sh; \
fi
# Copy user -autoboot_command overrides (batocera.linux/batocera.linux#11706)
diff --git a/package/batocera/emulators/rpcs3/Config.in b/package/batocera/emulators/rpcs3/Config.in
index e66645123b3..cb9da3363b0 100644
--- a/package/batocera/emulators/rpcs3/Config.in
+++ b/package/batocera/emulators/rpcs3/Config.in
@@ -22,6 +22,7 @@ config BR2_PACKAGE_RPCS3
select BR2_PACKAGE_SDL3
select BR2_PACKAGE_WOLFSSL
select BR2_PACKAGE_WOLFSSL_ALL
+ select BR2_PACKAGE_BATOCERA_LAUNCH_RPCS3
help
RPCS3 is a multi-platform open-source Sony PlayStation 3 emulator.
diff --git a/package/batocera/emulators/tic80/tic80.emulator.yml b/package/batocera/emulators/tic80/tic80.emulator.yml
new file mode 100644
index 00000000000..84dbfe4e8e1
--- /dev/null
+++ b/package/batocera/emulators/tic80/tic80.emulator.yml
@@ -0,0 +1,16 @@
+features:
+ - padtokeyboard
+shared_features:
+ - powermode
+ - tdp
+ - videomode
+ - bezel
+ - bezel_stretch
+ - hud
+ - hud_corner
+ - bezel.tattoo
+ - bezel.tattoo_corner
+ - bezel.tattoo_file
+ - bezel.resize_tattoo
+systems:
+ - tic80
diff --git a/package/batocera/emulators/tic80/tic80.mk b/package/batocera/emulators/tic80/tic80.mk
index 42f69740ea5..c0e29d351dc 100644
--- a/package/batocera/emulators/tic80/tic80.mk
+++ b/package/batocera/emulators/tic80/tic80.mk
@@ -10,6 +10,7 @@ TIC80_SITE_METHOD = git
TIC80_GIT_SUBMODULES = YES
TIC80_LICENSE = MIT
TIC80_LICENSE_FILES = LICENSE
+TIC80_EMULATOR_INFO = tic80.emulator.yml
TIC80_DEPENDENCIES = sdl2 host-pkgconf zlib libcurl
@@ -37,3 +38,4 @@ define TIC80_INSTALL_TARGET_CMDS
endef
$(eval $(cmake-package))
+$(eval $(emulator-info-package))
diff --git a/package/batocera/gpu/mali-G52/mali-G52.mk b/package/batocera/gpu/mali-G52/mali-G52.mk
index d28ce83c75f..e8591bcbcdc 100644
--- a/package/batocera/gpu/mali-G52/mali-G52.mk
+++ b/package/batocera/gpu/mali-G52/mali-G52.mk
@@ -28,7 +28,7 @@ define MALI_G52_INSTALL_TARGET_CMDS
cp -R $(@D)/usr/lib/aarch64-linux-gnu/* $(TARGET_DIR)/usr/lib/
install -D -m 0755 \
$(BR2_EXTERNAL_BATOCERA_PATH)/package/batocera/gpu/mali-G52/mali_g52_gpu_launch_hooks.sh \
- $(TARGET_DIR)/usr/share/batocera/configgen/scripts/mali_g52_gpu_launch_hooks.sh
+ $(TARGET_DIR)/usr/share/batocera/launch/scripts/mali_g52_gpu_launch_hooks.sh
endef
#MALI_G52_GPU = bifrost-g52
diff --git a/package/batocera/pkg-local-python.mk b/package/batocera/pkg-local-python.mk
index fd99b94dc3e..49077c07e06 100644
--- a/package/batocera/pkg-local-python.mk
+++ b/package/batocera/pkg-local-python.mk
@@ -15,7 +15,8 @@ $(2)_SOURCE =
$(2)_OVERRIDE_SRCDIR ?= $$($(2)_PKGDIR)
$(2)_OVERRIDE_SRCDIR_RSYNC_EXCLUSIONS ?= --exclude=".*" --exclude="**/__pycache__/" \
--exclude="dist" --exclude="*.mk" --exclude="Config.in" \
- --exclude="**/py.typed" --exclude="tests"
+ --exclude="**/py.typed" --exclude="tests" \
+ $($(2)_LOCAL_PYTHON_EXCLUSIONS:%=--exclude="%")
$(call inner-python-package,$(1),$(2),$(3),$(4))
endef
diff --git a/package/batocera/ports/cdogs/Config.in b/package/batocera/ports/cdogs/Config.in
index 1a5994460c3..d8ca1c364ad 100644
--- a/package/batocera/ports/cdogs/Config.in
+++ b/package/batocera/ports/cdogs/Config.in
@@ -6,6 +6,7 @@ config BR2_PACKAGE_CDOGS
select BR2_PACKAGE_PYTHON_PROTOBUF
select BR2_PACKAGE_HOST_PYTHON_PROTOBUF
select BR2_PACKAGE_ENET
+ select BR2_PACKAGE_BATOCERA_LAUNCH_CDOGS
depends on BR2_INSTALL_LIBSTDCPP
help
Classic overhead run-and-gun game
diff --git a/package/batocera/ports/cgenius/Config.in b/package/batocera/ports/cgenius/Config.in
index 61dd4456b4e..0983f24e9ad 100644
--- a/package/batocera/ports/cgenius/Config.in
+++ b/package/batocera/ports/cgenius/Config.in
@@ -7,6 +7,7 @@ config BR2_PACKAGE_CGENIUS
select BR2_PACKAGE_BOOST
select BR2_PACKAGE_XXD
select BR2_PACKAGE_PYTHON3_CONFIGOBJ
+ select BR2_PACKAGE_BATOCERA_LAUNCH_CGENIUS
help
Commander Genius - Commander Keen Engine
diff --git a/package/batocera/ports/fallout1-ce/Config.in b/package/batocera/ports/fallout1-ce/Config.in
index 59bd3f890cb..baa22f19650 100644
--- a/package/batocera/ports/fallout1-ce/Config.in
+++ b/package/batocera/ports/fallout1-ce/Config.in
@@ -1,6 +1,7 @@
config BR2_PACKAGE_FALLOUT1_CE
bool "fallout1-ce"
select BR2_PACKAGE_SDL2
+ select BR2_PACKAGE_BATOCERA_LAUNCH_FALLOUT
help
Fallout 1 CE - Open Source Fallout 1 engine
diff --git a/package/batocera/ports/fallout2-ce/Config.in b/package/batocera/ports/fallout2-ce/Config.in
index c6c861ae5c2..1ebec3fd926 100644
--- a/package/batocera/ports/fallout2-ce/Config.in
+++ b/package/batocera/ports/fallout2-ce/Config.in
@@ -1,6 +1,7 @@
config BR2_PACKAGE_FALLOUT2_CE
bool "fallout2-ce"
select BR2_PACKAGE_SDL2
+ select BR2_PACKAGE_BATOCERA_LAUNCH_FALLOUT
help
Fallout 2 CE - Open Source Fallout 2 engine
diff --git a/package/batocera/ports/openjazz/Config.in b/package/batocera/ports/openjazz/Config.in
index fcb85204d96..6138fd73767 100644
--- a/package/batocera/ports/openjazz/Config.in
+++ b/package/batocera/ports/openjazz/Config.in
@@ -2,6 +2,7 @@ config BR2_PACKAGE_OPENJAZZ
bool "openjazz"
depends on BR2_INSTALL_LIBSTDCPP
select BR2_PACKAGE_SDL2
+ select BR2_PACKAGE_BATOCERA_LAUNCH_OPENJAZZ
help
OpenJazz is a free, open-source version
of the classic Jazz Jackrabbit games.
diff --git a/package/batocera/ports/openjk/Config.in b/package/batocera/ports/openjk/Config.in
index 9aa485998b6..a22ed3bd16d 100644
--- a/package/batocera/ports/openjk/Config.in
+++ b/package/batocera/ports/openjk/Config.in
@@ -4,6 +4,7 @@ config BR2_PACKAGE_OPENJK
select BR2_PACKAGE_LIBPNG
select BR2_PACKAGE_SDL2
select BR2_PACKAGE_ZLIB
+ select BR2_PACKAGE_BATOCERA_LAUNCH_OPENJK
help
Community effort to maintain and improve Jedi Academy (SP & MP)
and Jedi Outcast (SP only) released by Raven Software.
diff --git a/package/batocera/ports/openjkdf2/Config.in b/package/batocera/ports/openjkdf2/Config.in
index 4e7527e21fc..988d4ce233a 100644
--- a/package/batocera/ports/openjkdf2/Config.in
+++ b/package/batocera/ports/openjkdf2/Config.in
@@ -4,6 +4,7 @@ config BR2_PACKAGE_OPENJKDF2
select BR2_PACKAGE_LIBFREEGLUT
select BR2_PACKAGE_OPENAL
select BR2_PACKAGE_SDL2
+ select BR2_PACKAGE_BATOCERA_LAUNCH_OPENJKDF2
help
A cross-platform reimplementation of
Jedi Knight Dark Forces 2 written in C.
diff --git a/package/batocera/ports/openmohaa/Config.in b/package/batocera/ports/openmohaa/Config.in
index dec955eed3a..f56e5b3b3ce 100644
--- a/package/batocera/ports/openmohaa/Config.in
+++ b/package/batocera/ports/openmohaa/Config.in
@@ -3,6 +3,7 @@ config BR2_PACKAGE_OPENMOHAA
select BR2_PACKAGE_LIBCURL
select BR2_PACKAGE_OPENAL
select BR2_PACKAGE_SDL2
+ select BR2_PACKAGE_BATOCERA_LAUNCH_OPENMOHAA
help
Open re-implementation of Medal of Honor: Allied Assault
including Spearhead and Breakthrough expansions with modern features.
diff --git a/package/batocera/ports/theforceengine/theforceengine.mk b/package/batocera/ports/theforceengine/theforceengine.mk
index c15e3867e85..46db5e9ffc9 100644
--- a/package/batocera/ports/theforceengine/theforceengine.mk
+++ b/package/batocera/ports/theforceengine/theforceengine.mk
@@ -12,7 +12,7 @@ THEFORCEENGINE_LICENSE = GPLv2
THEFORCEENGINE_LICENSE_FILE = LICENSE
THEFORCEENGINE_EMULATOR_INFO = theforceengine.emulator.yml
-# be sure to update configgen if the patch version changes
+# be sure to update batocera-launch if the patch version changes
THEFORCEENGINE_PATCH_VERSION = df_patch4
THEFORCEENGINE_PATCH_SOURCE = $(THEFORCEENGINE_PATCH_VERSION).zip
THEFORCEENGINE_EXTRA_DOWNLOADS = \
@@ -67,4 +67,4 @@ THEFORCEENGINE_POST_INSTALL_TARGET_HOOKS += THEFORCEENGINE_PATCH_ZIP
THEFORCEENGINE_POST_INSTALL_TARGET_HOOKS += THEFORCEENGINE_EVMAPY
$(eval $(cmake-package))
-$(eval $(emulator-info-package))
\ No newline at end of file
+$(eval $(emulator-info-package))
diff --git a/package/batocera/utils/evmapy/batocera-evmapy b/package/batocera/utils/evmapy/batocera-evmapy
index 4a5b9877056..58b5028650f 100755
--- a/package/batocera/utils/evmapy/batocera-evmapy
+++ b/package/batocera/utils/evmapy/batocera-evmapy
@@ -7,7 +7,7 @@ case "${1}" in
touch /var/run/evmapy/ready || exit 1
inotifywait /var/run/evmapy/ready -t 5 -q & # wait the evmapy ready flag
X=$!
- nohup evmapy &
+ nohup evmapy /dev/null 2>&1 &
wait "${X}"
exit 0
;;
diff --git a/pyproject.toml b/pyproject.toml
index cd83debb760..cfb24cff84d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,9 +4,11 @@ dev = [
"ruff==0.16.3",
"pyright[nodejs]==1.1.411",
"pytest==9.1.1",
+ "pytest-asyncio==1.4.0",
"pytest-mock==3.15.1",
"pytest-cov==7.1.0",
"pytest-xdist==3.8.0",
+ "pyfakefs==6.2.0",
"coverage[toml]==7.15.4",
"pygobject-stubs @ git+https://github.com/pygobject/pygobject-stubs.git@959c470767cd44a74912ba182e9fb202d0d96519",
]
@@ -24,6 +26,8 @@ constraint-dependencies = [
"pyudev==0.24.4",
"qrcode==8.2",
"requests==2.33.1",
+ "aiohttp==3.13.5",
+ "uvloop==0.22.1",
"ruamel.yaml==0.19.0",
"toml==0.10.2",
"typing-extensions==4.15.0",
@@ -34,6 +38,7 @@ config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3" } }
[tool.uv.sources]
batocera-common = { workspace = true }
+batocera-launch = { workspace = true }
[tool.uv.workspace]
members = [
@@ -51,11 +56,16 @@ addopts = [
"--cov-report=term",
]
strict = true
+asyncio_mode = "auto"
[tool.coverage.run]
omit = ["**/python-src/*/tests/**"]
[tool.pyright]
+include = [
+ "python-src",
+ "package/batocera/core/batocera-configgen/configgen",
+]
exclude = [
".venv*",
"buildroot*",
@@ -135,12 +145,17 @@ quote-style = "single"
preview = true
explicit-preview-rules = true
select = [
- "TC", "PYI", "I", "UP", "F", "FA", "E", "W", "B", "C4", "SIM", "PTH", "G", "PERF", "PIE", "RET", "RUF"
+ "TC", "PYI", "I", "UP", "F", "FA", "E", "W", "B", "C4", "SIM", "PTH", "G", "PERF", "PIE", "RET", "PT", "RUF"
]
ignore = ["SIM105", "SIM108", "E501", "RUF051"]
[tool.ruff.lint.isort]
extra-standard-library = ["typing_extensions", "_typeshed"]
-known-first-party = ["batocera_common", "configgen", "batocera_es_system"]
+known-first-party = [
+ "batocera_common",
+ "batocera_launch",
+ "configgen",
+ "batocera_es_system"
+]
combine-as-imports = true
required-imports = ["from __future__ import annotations"]
diff --git a/python-src/batocera-es-system/batocera_es_system/build_es_data.py b/python-src/batocera-es-system/batocera_es_system/build_es_data.py
index cf17d8d067c..62c69300b16 100644
--- a/python-src/batocera-es-system/batocera_es_system/build_es_data.py
+++ b/python-src/batocera-es-system/batocera_es_system/build_es_data.py
@@ -9,7 +9,7 @@
Registry,
)
from batocera_es_system.roms import build as _build_roms_dir
-from batocera_es_system.shared import ConfiggenDefaults
+from batocera_es_system.shared import Defaults
from batocera_es_system.translations import build as _build_translations
@@ -18,7 +18,7 @@ def main() -> None:
parser.add_argument('--es-systems-yml', type=Path, help='es_systems.yml definition file')
parser.add_argument('--locales-dir', type=Path, required=True, help='Path to locales directory')
parser.add_argument('--roms-dir', type=Path, required=True, help='Path to roms directory')
- parser.add_argument('--configgen', type=Path, required=True, help='Path to batocera-configgen staging directory')
+ parser.add_argument('--defaults', type=Path, required=True, help='Path to batocera-launch staging directory')
parser.add_argument('--keys-dir', type=Path, required=True, help='Path to batocera-es-system keys directory')
parser.add_argument('--output', type=Path, required=True, help='Path to build directory')
parser.add_argument('--arch', help='Target architecture', default=None)
@@ -32,9 +32,9 @@ def main() -> None:
registry = Registry.load_path_file(args.info_path_file)
- configgen_defaults = ConfiggenDefaults.for_directory(args.configgen)
+ defaults = Defaults.for_directory(args.defaults)
systems_data = _build_es_systems(
- registry.get_systems_metadata(configgen_defaults),
+ registry.get_systems_metadata(defaults),
args.es_systems_yml,
args.output,
)
diff --git a/python-src/batocera-es-system/batocera_es_system/es_systems.py b/python-src/batocera-es-system/batocera_es_system/es_systems.py
index 11c2e628639..22731d91bdf 100644
--- a/python-src/batocera-es-system/batocera_es_system/es_systems.py
+++ b/python-src/batocera-es-system/batocera_es_system/es_systems.py
@@ -25,7 +25,7 @@
_DEFAULT_PARENTPATH: Final = '/userdata/roms'
-_DEFAULT_COMMAND: Final = 'emulatorlauncher %CONTROLLERSCONFIG% -system %SYSTEM% -rom %ROM% -gameinfoxml %GAMEINFOXML% -systemname %SYSTEMNAME%'
+_DEFAULT_COMMAND: Final = 'batocera-launch %CONTROLLERSCONFIG% -system %SYSTEM% -rom %ROM% -gameinfoxml %GAMEINFOXML% -systemname %SYSTEMNAME%'
def _extensions_to_xml(values: Iterable[str], /) -> str:
diff --git a/python-src/batocera-es-system/batocera_es_system/registry.py b/python-src/batocera-es-system/batocera_es_system/registry.py
index a65b17e4368..516afff0256 100644
--- a/python-src/batocera-es-system/batocera_es_system/registry.py
+++ b/python-src/batocera-es-system/batocera_es_system/registry.py
@@ -12,7 +12,7 @@
if TYPE_CHECKING:
from _typeshed import StrPath
- from batocera_es_system.shared import ConfiggenDefaults
+ from batocera_es_system.shared import Defaults
class KeysActionBase(TypedDict):
@@ -699,14 +699,14 @@ def emulators_by_system(self) -> EmulatorsBySystemMapping:
return emulators_by_system
- def get_systems_metadata(self, configgen_defaults: ConfiggenDefaults, /) -> SystemsMetadataMapping:
+ def get_systems_metadata(self, defaults: Defaults, /) -> SystemsMetadataMapping:
system_core_data: SystemsMetadataDict = defaultdict(lambda: defaultdict(dict))
for system, emulator_name, _, core_name in self._iter_system_emulator_cores():
system_core_data[system.name][emulator_name][core_name] = {
'incompatible_extensions': system.exclude_extensions or [],
- 'default': emulator_name == configgen_defaults.get(system.name, 'emulator')
- and core_name == configgen_defaults.get(system.name, 'core'),
+ 'default': emulator_name == defaults.get(system.name, 'emulator')
+ and core_name == defaults.get(system.name, 'core'),
}
return system_core_data
diff --git a/python-src/batocera-es-system/batocera_es_system/shared.py b/python-src/batocera-es-system/batocera_es_system/shared.py
index a5e2e877d54..43db683024e 100644
--- a/python-src/batocera-es-system/batocera_es_system/shared.py
+++ b/python-src/batocera-es-system/batocera_es_system/shared.py
@@ -2,7 +2,7 @@
from collections import deque
from dataclasses import InitVar, dataclass, field
-from typing import TYPE_CHECKING, Any, Final, NotRequired, Self, overload
+from typing import TYPE_CHECKING, Any, NotRequired, Self, overload
from typing_extensions import Sentinel, TypedDict
from batocera_common.yaml import safe_load_yaml
@@ -48,7 +48,7 @@ class SystemDict(TypedDict, extra_items=str):
type SystemsDataMapping = Mapping[str, SystemDict]
-# es_systems.yml plus configgen defaults
+# es_systems.yml plus batocera-launch defaults
class DefaultsDict(TypedDict):
@@ -56,7 +56,7 @@ class DefaultsDict(TypedDict):
core: str | None
-MISSING: Final = Sentinel('MISSING')
+MISSING = Sentinel('MISSING')
def get_deep_value(mapping: Mapping[str, Any], first_key: str, /, *keys: str) -> Any | MISSING:
@@ -95,7 +95,7 @@ def to_xml_attribute(name: str, string: str | int | None, /) -> str:
return f' {name}="{protect_xml(string)}"' if string else ''
-_NOTHING: Final = Sentinel('_NOTHING')
+_NOTHING = Sentinel('_NOTHING')
@dataclass(slots=True)
@@ -167,7 +167,7 @@ def wrap_tag(
@dataclass(slots=True)
-class ConfiggenDefaults:
+class Defaults:
defaults: dict[str, DefaultDict]
arch_defaults: dict[str, DefaultDict]
@@ -187,4 +187,4 @@ def for_defaults(cls, defaults: Path, arch_defaults: Path, /) -> Self:
@classmethod
def for_directory(cls, directory: Path, /) -> Self:
- return cls.for_defaults(directory / 'configgen-defaults.yml', directory / 'configgen-defaults-arch.yml')
+ return cls.for_defaults(directory / 'config.yml', directory / 'config-arch.yml')
diff --git a/python-src/batocera-es-system/batocera_es_system/systems_report.py b/python-src/batocera-es-system/batocera_es_system/systems_report.py
index 5d6e73806bf..4875e53c033 100755
--- a/python-src/batocera-es-system/batocera_es_system/systems_report.py
+++ b/python-src/batocera-es-system/batocera_es_system/systems_report.py
@@ -15,7 +15,7 @@
from batocera_es_system.registry import EmulatorInfo, EmulatorsBySystemMapping, EmulatorsMetadataMapping, Registry
from batocera_es_system.shared import (
MISSING,
- ConfiggenDefaults,
+ Defaults,
SystemDict,
SystemsData,
get_deep_value,
@@ -155,7 +155,7 @@ def _generate_target_system_report(
system_name: str,
system_data: SystemDict,
explanations: _ExplanationsDict,
- configgen_defaults: ConfiggenDefaults,
+ defaults: Defaults,
emulators_metadata: EmulatorsMetadataMapping,
all_system_emulators: Mapping[str, Mapping[str, EmulatorInfo]],
/,
@@ -229,8 +229,8 @@ def _generate_target_system_report(
return None
if nb_variants > 0 and not default_found:
- default_emulator = configgen_defaults.get(system_name, 'emulator')
- default_core = configgen_defaults.get(system_name, 'core')
+ default_emulator = defaults.get(system_name, 'emulator')
+ default_core = defaults.get(system_name, 'core')
missing_info.add_default(system_name, default_emulator, default_core)
@@ -249,7 +249,7 @@ def _generate_target_report(
target_dir: Path,
es_systems_data: SystemsData,
explanations: _ExplanationsDict,
- configgen_dir: Path,
+ defaults_dir: Path,
all_emulators_by_system: EmulatorsBySystemMapping,
/,
*,
@@ -264,10 +264,8 @@ def _generate_target_report(
missing=missing_info.files,
buildroot_mapping=buildroot_mapping,
)
- configgen_defaults = ConfiggenDefaults.for_defaults(
- configgen_dir / 'configgen-defaults.yml', configgen_dir / f'configgen-defaults-{target}.yml'
- )
- systems_metadata = registry.get_systems_metadata(configgen_defaults)
+ defaults = Defaults.for_defaults(defaults_dir / 'config.yml', defaults_dir / f'config-{target}.yml')
+ systems_metadata = registry.get_systems_metadata(defaults)
return {
system_name: target_system_report
@@ -278,7 +276,7 @@ def _generate_target_report(
system_name,
system_data,
explanations,
- configgen_defaults,
+ defaults,
systems_metadata.get(system_name, {}),
all_emulators_by_system.get(system_name, {}),
missing_info=missing_info,
@@ -292,7 +290,7 @@ def _generate_systems_report(
reports_data_dir: Path,
es_systems_yml: Path,
explanations_yml: Path,
- configgen_dir: Path,
+ defaults_dir: Path,
output_file: Path,
/,
*,
@@ -324,7 +322,7 @@ def _generate_systems_report(
target_dir,
es_systems_data,
explanations,
- configgen_dir,
+ defaults_dir,
all_emulators_by_system,
missing_report=missing_report,
buildroot_mapping=buildroot_mapping,
@@ -344,7 +342,7 @@ def main() -> None:
parser.add_argument('reports_data_dir', type=Path, help='Directory containing reports data directories')
parser.add_argument('es_systems_yml', type=Path, help='es_systems.yml definition file')
parser.add_argument('explanations_yml', type=Path, help='explanations.yml definition file')
- parser.add_argument('configgen_dir', type=Path, help='Path to configgen configs directory')
+ parser.add_argument('defaults_dir', type=Path, help='Path to batocera-launch defaults directory')
parser.add_argument('dest', type=Path, help='Output file')
parser.add_argument(
'-m',
@@ -369,7 +367,7 @@ def main() -> None:
args.reports_data_dir,
args.es_systems_yml,
args.explanations_yml,
- args.configgen_dir,
+ args.defaults_dir,
args.dest,
buildroot_mapping=buildroot_mapping,
)
diff --git a/python-src/batocera-es-system/pyproject.toml b/python-src/batocera-es-system/pyproject.toml
index 2cc290e68fb..7f9356c1bc7 100644
--- a/python-src/batocera-es-system/pyproject.toml
+++ b/python-src/batocera-es-system/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "batocera-es-system"
-version = '44.0.0'
+version = '44.0'
dependencies = [
'batocera-common',
'typing-extensions',
diff --git a/python-src/batocera-launch-cdogs/Config.in b/python-src/batocera-launch-cdogs/Config.in
new file mode 100644
index 00000000000..a39d3d0b182
--- /dev/null
+++ b/python-src/batocera-launch-cdogs/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_CDOGS
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-cdogs/batocera-launch-cdogs.mk b/python-src/batocera-launch-cdogs/batocera-launch-cdogs.mk
new file mode 100644
index 00000000000..a6017b472bc
--- /dev/null
+++ b/python-src/batocera-launch-cdogs/batocera-launch-cdogs.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-cdogs
+#
+################################################################################
+
+BATOCERA_LAUNCH_CDOGS_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_CDOGS_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-cdogs/batocera_launch_cdogs/__init__.py b/python-src/batocera-launch-cdogs/batocera_launch_cdogs/__init__.py
new file mode 100644
index 00000000000..9062582fb11
--- /dev/null
+++ b/python-src/batocera-launch-cdogs/batocera_launch_cdogs/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import CDogs as CDogs
diff --git a/python-src/batocera-launch-cdogs/batocera_launch_cdogs/emulator.py b/python-src/batocera-launch-cdogs/batocera_launch_cdogs/emulator.py
new file mode 100644
index 00000000000..857f98473af
--- /dev/null
+++ b/python-src/batocera-launch-cdogs/batocera_launch_cdogs/emulator.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+import logging
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import BatoceraException, Command, Emulator, HotkeysContext
+
+_logger = logging.getLogger(__name__)
+
+_ROM_DIR: Final = ROMS / 'cdogs'
+_ASSET_DIR_NAMES: Final = [
+ 'music/briefing',
+ 'music/end',
+ 'music/game',
+ 'music/victory',
+ 'music/lose',
+ 'music/menu',
+ 'missions/antares3consp.cdogscpn',
+ 'missions/spacepirates.cdogscpn/graphics',
+ 'missions/graveintent.cdogscpn',
+ 'missions/harmful_crysalis.cdogscpn/sounds',
+ 'missions/harmful_crysalis.cdogscpn/graphics',
+ 'missions/bem.cdogscpn',
+ 'missions/ai_insurgency.cdogscpn/graphics',
+ 'missions/doom.cdogscpn/sounds',
+ 'missions/doom.cdogscpn/graphics/chars/bodies/demon',
+ 'missions/custom/Wuzzy/gungame.cdogscpn/sounds',
+ 'missions/custom/Wuzzy/gungame.cdogscpn/graphics',
+ 'missions/custom/techdemo/spawnertest.cdogscpn',
+ 'missions/custom/techdemo/soundtest.cdogscpn/sounds',
+ 'missions/custom/techdemo/soundtest.cdogscpn/graphics',
+ 'missions/custom/techdemo/funwithguns.cdogscpn/sounds',
+ 'missions/custom/techdemo/funwithguns.cdogscpn/graphics',
+ 'missions/custom/techdemo/spritetest.cdogscpn',
+ 'missions/custom/techdemo/tiletest.cdogscpn',
+ 'missions/custom/techdemo/rescue.cdogscpn',
+ 'missions/custom/techdemo/minimal.cdogscpn',
+ 'missions/custom/techdemo/cyberdogs.cdogscpn/graphics/chars',
+ 'missions/custom/techdemo/cyberdogs.cdogscpn/graphics/chars/guns',
+ 'missions/custom/techdemo/static.cdogscpn',
+ 'missions/WOLF3D',
+ 'missions/zombie.cdogscpn',
+ 'missions/Sand.cdogscpn/sounds',
+ 'missions/Sand.cdogscpn/graphics',
+ 'missions/devhell.cdogscpn',
+ 'missions/distress.cdogscpn',
+ 'missions/ai_insurgency_2.cdogscpn/graphics',
+ 'missions/ogre.cdogscpn',
+ 'missions/most_classified_enemy.cdogscpn',
+ 'missions/terror.cdogscpn',
+ 'sounds/boom',
+ 'sounds/footsteps/wood',
+ 'sounds/footsteps/tile',
+ 'sounds/footsteps/mech',
+ 'sounds/footsteps/bones',
+ 'sounds/footsteps/gravel',
+ 'sounds/footsteps/grass',
+ 'sounds/footsteps/dog',
+ 'sounds/footsteps/water',
+ 'sounds/footsteps/metal',
+ 'sounds/footsteps/boots',
+ 'sounds/hits/dog_growl',
+ 'sounds/hits/knife_flesh',
+ 'sounds/hits/petrify',
+ 'sounds/hits/flesh',
+ 'sounds/hits/fist_flesh',
+ 'sounds/hits/fire',
+ 'sounds/hits/hard',
+ 'sounds/hits/knife_hard',
+ 'sounds/hits/gas',
+ 'sounds/slide',
+ 'sounds/chars/die/ogre',
+ 'sounds/chars/die/mech',
+ 'sounds/chars/die/man',
+ 'sounds/chars/die/zombie',
+ 'sounds/chars/die/alien',
+ 'sounds/chars/die/dog',
+ 'sounds/chars/die/woman',
+ 'sounds/chars/alert/ogre',
+ 'sounds/chars/alert/mech',
+ 'sounds/chars/alert/man',
+ 'sounds/chars/alert/zombie',
+ 'sounds/chars/alert/alien',
+ 'sounds/chars/alert/dog',
+ 'sounds/chars/alert/woman',
+ 'sounds/bang',
+ 'sounds/ricochet',
+ 'sounds/explosion',
+ 'graphics/hud',
+ 'graphics/tile/wood',
+ 'graphics/tile/grid',
+ 'graphics/tile/recessed2',
+ 'graphics/tile/grate',
+ 'graphics/tile/tile',
+ 'graphics/tile/hex',
+ 'graphics/tile/biggrid',
+ 'graphics/tile/checker',
+ 'graphics/tile/wood2',
+ 'graphics/tile/biggrid2',
+ 'graphics/tile/slab',
+ 'graphics/tile/recessed',
+ 'graphics/tile/dirt',
+ 'graphics/tile/cobble',
+ 'graphics/tile/striped',
+ 'graphics/tile/smallsquare2',
+ 'graphics/tile/flat',
+ 'graphics/tile/water',
+ 'graphics/tile/stone',
+ 'graphics/tile/smallsquare',
+ 'graphics/tile/metal',
+ 'graphics/door/office2/base/key',
+ 'graphics/door/office/base/key',
+ 'graphics/door/dungeon/base/key',
+ 'graphics/door/alien2/base/key',
+ 'graphics/door/dungeon2/base/key',
+ 'graphics/door/doom/base/key',
+ 'graphics/door/prison/base/key',
+ 'graphics/door/tech/base/key',
+ 'graphics/door/alien/base/key',
+ 'graphics/door/blast/base/key',
+ 'graphics/wall/techwall',
+ 'graphics/wall/colony',
+ 'graphics/wall/greenstain',
+ 'graphics/wall/plasteel',
+ 'graphics/wall/stone2',
+ 'graphics/wall/plasteel2',
+ 'graphics/wall/brick',
+ 'graphics/wall/station',
+ 'graphics/wall/rock',
+ 'graphics/wall/bunker',
+ 'graphics/wall/steel',
+ 'graphics/wall/panel',
+ 'graphics/wall/cobble',
+ 'graphics/wall/granite',
+ 'graphics/wall/hightech',
+ 'graphics/wall/stone',
+ 'graphics/wall/carbon2',
+ 'graphics/wall/carbon',
+ 'graphics/wall/steelwood',
+ 'graphics/wall/brick2',
+ 'graphics/wall/support',
+ 'graphics/editor/cursors',
+ 'graphics/keys/cube',
+ 'graphics/keys/office',
+ 'graphics/keys/dungeon',
+ 'graphics/keys/card',
+ 'graphics/keys/chip',
+ 'graphics/keys/plain2',
+ 'graphics/keys/plain',
+ 'graphics/keys/cube2',
+ 'graphics/chars/glasses',
+ 'graphics/chars/bodies/soldier',
+ 'graphics/chars/bodies/big_soldier',
+ 'graphics/chars/bodies/skinny',
+ 'graphics/chars/bodies/big_skinny',
+ 'graphics/chars/bodies/mech',
+ 'graphics/chars/bodies/none',
+ 'graphics/chars/bodies/base',
+ 'graphics/chars/bodies/dog',
+ 'graphics/chars/bodies/big',
+ 'graphics/chars/hats',
+ 'graphics/chars/heads',
+ 'graphics/chars/facehairs',
+ 'graphics/chars/hairs',
+ 'graphics/chars/guns',
+ 'graphics/exits/plate',
+ 'graphics/exits/hazard',
+ 'graphics/exits/hazard2',
+ 'graphics/exits/plate2',
+ 'graphics/particles',
+ 'dogfights/doom_e1m1.cdogscpn/graphics',
+ 'dogfights/cubicle.cdogscpn',
+ 'dogfights/temple_of_carnage.cdogscpn/graphics',
+ 'dogfights/de_dust.cdogscpn',
+ 'dogfights/map07.cdogscpn/sounds',
+ 'dogfights/map07.cdogscpn/graphics',
+ 'dogfights/dungeon.cdogscpn',
+ 'dogfights/dwango5_map01.cdogscpn/sounds',
+ 'dogfights/dwango5_map01.cdogscpn/graphics',
+ 'dogfights/spells_n_spikes.cdogscpn/graphics',
+ 'doc',
+ 'data/.wolf3d/WL6.cdogscpn',
+ 'data/.wolf3d/SD2.cdogscpn',
+ 'data/.wolf3d/SD2data.cdogscpn/graphics/wall/landscape',
+ 'data/.wolf3d/SD2data.cdogscpn/graphics/chars/bodies/angel',
+ 'data/.wolf3d/SD2data.cdogscpn/graphics/chars/bodies/bat',
+ 'data/.wolf3d/SD2data.cdogscpn/graphics/chars/bodies/big_mutant',
+ 'data/.wolf3d/SD2data.cdogscpn/graphics/chars/bodies/wizard',
+ 'data/.wolf3d/SD2data.cdogscpn/graphics/chars/hairs',
+ 'data/.wolf3d/SD2data.cdogscpn/graphics/chars/guns',
+ 'data/.wolf3d/SD3.cdogscpn',
+ 'data/.wolf3d/WL1.cdogscpn',
+ 'data/.wolf3d/common.cdogscpn/graphics/wall/landscape',
+ 'data/.wolf3d/common.cdogscpn/graphics/chars/bodies/mutant',
+ 'data/.wolf3d/common.cdogscpn/graphics/chars/bodies/angel',
+ 'data/.wolf3d/common.cdogscpn/graphics/chars/bodies/big_mutant',
+ 'data/.wolf3d/common.cdogscpn/graphics/chars/bodies/wizard',
+ 'data/.wolf3d/common.cdogscpn/graphics/chars/hairs',
+ 'data/.wolf3d/common.cdogscpn/graphics/chars/guns',
+ 'data/.wolf3d/SOD.cdogscpn',
+ 'data/.wolf3d/SD3data.cdogscpn/graphics/wall/landscape',
+ 'data/.wolf3d/SD3data.cdogscpn/graphics/chars/bodies/angel',
+ 'data/.wolf3d/SD3data.cdogscpn/graphics/chars/bodies/bat',
+ 'data/.wolf3d/SD3data.cdogscpn/graphics/chars/bodies/big_mutant',
+ 'data/.wolf3d/SD3data.cdogscpn/graphics/chars/bodies/wizard',
+ 'data/.wolf3d/SD3data.cdogscpn/graphics/chars/hairs',
+ 'data/.wolf3d/SD3data.cdogscpn/graphics/chars/guns',
+]
+
+
+@cached_dataclass
+class CDogs(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'cdogs',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4'], 'menu': 'KEY_ESC', 'pause': 'KEY_ESC'},
+ }
+
+ async def configure(self) -> Command:
+ for assetdir in _ASSET_DIR_NAMES:
+ if not (_ROM_DIR / assetdir).is_dir():
+ _logger.error(
+ 'ERROR: Game assets not installed. You can get them from the Batocera Content Downloader.'
+ )
+ raise BatoceraException(
+ 'Game assets not installed. You can get them from the Batocera Content Downloader.'
+ )
+
+ return Command(['cdogs'])
diff --git a/python-src/batocera-launch-cdogs/pyproject.toml b/python-src/batocera-launch-cdogs/pyproject.toml
new file mode 100644
index 00000000000..aca5cf21b1f
--- /dev/null
+++ b/python-src/batocera-launch-cdogs/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-cdogs"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+cdogs = 'batocera_launch_cdogs:CDogs'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_cdogs"]
diff --git a/python-src/batocera-launch-cgenius/Config.in b/python-src/batocera-launch-cgenius/Config.in
new file mode 100644
index 00000000000..912bb4b174b
--- /dev/null
+++ b/python-src/batocera-launch-cgenius/Config.in
@@ -0,0 +1,5 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_CGENIUS
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
+ depends on BR2_PACKAGE_PYTHON3_CONFIGOBJ
diff --git a/python-src/batocera-launch-cgenius/batocera-launch-cgenius.mk b/python-src/batocera-launch-cgenius/batocera-launch-cgenius.mk
new file mode 100644
index 00000000000..4b5adeef169
--- /dev/null
+++ b/python-src/batocera-launch-cgenius/batocera-launch-cgenius.mk
@@ -0,0 +1,13 @@
+################################################################################
+#
+# batocera-launch-cgenius
+#
+################################################################################
+
+BATOCERA_LAUNCH_CGENIUS_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_CGENIUS_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch \
+ python3-configobj
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-cgenius/batocera_launch_cgenius/__init__.py b/python-src/batocera-launch-cgenius/batocera_launch_cgenius/__init__.py
new file mode 100644
index 00000000000..943c94c63f2
--- /dev/null
+++ b/python-src/batocera-launch-cgenius/batocera_launch_cgenius/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import CGenius as CGenius
diff --git a/python-src/batocera-launch-cgenius/batocera_launch_cgenius/emulator.py b/python-src/batocera-launch-cgenius/batocera_launch_cgenius/emulator.py
new file mode 100644
index 00000000000..88dd712f141
--- /dev/null
+++ b/python-src/batocera-launch-cgenius/batocera_launch_cgenius/emulator.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import shutil
+from typing import Final
+
+from configobj import ConfigObj
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_CONFIG_DIR: Final = CONFIGS / 'cgenius'
+_CONFIG_FILE: Final = _CONFIG_DIR / 'cgenius.cfg'
+_ROM_DIR: Final = ROMS / 'cgenius'
+
+_CONTROLLER_MAPPING: Final = {
+ 'a': 'Fire',
+ 'b': 'Jump',
+ 'pageup': 'Camlead',
+ 'x': 'Status',
+ 'y': 'Pogo',
+ 'pagedown': 'Run',
+ 'up': 'Up',
+ 'down': 'Down',
+ 'left': 'Left',
+ 'right': 'Right',
+}
+
+
+@cached_dataclass
+class CGenius(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'cgenius',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F6',
+ 'restore_state': 'KEY_F9',
+ },
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.config.get('cgenius_aspect') in {'16:9', '16:10'} else 4 / 3
+
+ async def configure(self) -> Command:
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+
+ if _CONFIG_FILE.exists():
+ config = ConfigObj(infile=str(_CONFIG_FILE))
+ else:
+ config = ConfigObj()
+ config.filename = str(_CONFIG_FILE)
+
+ if 'FileHandling' not in config:
+ config['FileHandling'] = {}
+ config['FileHandling']['EnableLogfile'] = 'false'
+ config['FileHandling']['SearchPath1'] = str(_ROM_DIR)
+ config['FileHandling']['SearchPath2'] = str(_ROM_DIR / 'games')
+
+ if 'Video' not in config:
+ config['Video'] = {}
+ config['Video']['aspect'] = self.config.get_str('cgenius_aspect', '4:3')
+ config['Video']['fullscreen'] = 'false'
+ config['Video']['integerScaling'] = 'false'
+ config['Video']['filter'] = self.config.get_str('cgenius_filter', 'none')
+ config['Video']['OGLfilter'] = self.config.get_str('cgenius_quality', 'nearest')
+
+ match self.config.get('cgenius_render'):
+ case '240':
+ game_width, game_height = '320', '240'
+ case '360':
+ game_width, game_height = '640', '360'
+ case '480':
+ game_width, game_height = '640', '480'
+ case _:
+ game_width, game_height = '320', '200'
+
+ for engine_section in ('Launcher', 'Vorticon', 'Galaxy', 'Cosmos'):
+ if engine_section not in config:
+ config[engine_section] = {}
+ config[engine_section]['gameWidth'] = game_width
+ config[engine_section]['gameHeight'] = game_height
+ config['Video']['ShowCursor'] = self.config.get_str('cgenius_cursor', 'false')
+
+ for controller in self.controllers[:4]:
+ input_section = f'input{controller.index}'
+ if input_section not in config:
+ config[input_section] = {}
+
+ for controller_input in controller.inputs.values():
+ if (action := _CONTROLLER_MAPPING.get(controller_input.name)) is None:
+ continue
+
+ if controller_input.type == 'hat':
+ binding = f'Joy{controller.index}-H{controller_input.value}'
+ else:
+ binding = f'Joy{controller.index}-{controller_input.type[0].upper()}{controller_input.id}'
+
+ config[input_section][action] = binding
+
+ config.write()
+ shutil.copy(_CONFIG_FILE, _ROM_DIR)
+
+ rom_dir = self.rom.parent
+ if rom_dir.is_relative_to(_ROM_DIR):
+ rom_dir = rom_dir.relative_to(_ROM_DIR)
+
+ return Command(['CGeniusExe', f'dir="{rom_dir}"'], env={'SDL_JOYSTICK_HIDAPI': '0'})
diff --git a/python-src/batocera-launch-cgenius/pyproject.toml b/python-src/batocera-launch-cgenius/pyproject.toml
new file mode 100644
index 00000000000..d3e36706f60
--- /dev/null
+++ b/python-src/batocera-launch-cgenius/pyproject.toml
@@ -0,0 +1,18 @@
+[project]
+name = "batocera-launch-cgenius"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+ 'configobj',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+cgenius = 'batocera_launch_cgenius:CGenius'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_cgenius"]
diff --git a/python-src/batocera-launch-drastic/Config.in b/python-src/batocera-launch-drastic/Config.in
new file mode 100644
index 00000000000..31fe7c4449e
--- /dev/null
+++ b/python-src/batocera-launch-drastic/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_DRASTIC
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-drastic/batocera-launch-drastic.mk b/python-src/batocera-launch-drastic/batocera-launch-drastic.mk
new file mode 100644
index 00000000000..528a258ad73
--- /dev/null
+++ b/python-src/batocera-launch-drastic/batocera-launch-drastic.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-drastic
+#
+################################################################################
+
+BATOCERA_LAUNCH_DRASTIC_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_DRASTIC_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-drastic/batocera_launch_drastic/__init__.py b/python-src/batocera-launch-drastic/batocera_launch_drastic/__init__.py
new file mode 100644
index 00000000000..b61b2564008
--- /dev/null
+++ b/python-src/batocera-launch-drastic/batocera_launch_drastic/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import Drastic as Drastic
diff --git a/python-src/batocera-launch-drastic/batocera_launch_drastic/emulator.py b/python-src/batocera-launch-drastic/batocera_launch_drastic/emulator.py
new file mode 100644
index 00000000000..855083ac5bf
--- /dev/null
+++ b/python-src/batocera-launch-drastic/batocera_launch_drastic/emulator.py
@@ -0,0 +1,296 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext, InputDict
+
+_SHARE_DIR: Final = Path('/usr/share/drastic')
+
+_LANGUAGE_MAPPING: Final = {
+ 'ja_JP': 0,
+ 'en_US': 1,
+ 'fr_FR': 2,
+ 'de_DE': 3,
+ 'it_IT': 4,
+ 'es_ES': 5,
+}
+
+_HAT_MASKS: Final = {
+ 'up': 1,
+ 'right': 2,
+ 'down': 4,
+ 'left': 8,
+}
+
+_DEFAULT_CODE: Final = '65535'
+
+_CONTROLS_B_UNBOUND: Final = {
+ 'controls_b[CONTROL_INDEX_UP]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_DOWN]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_LEFT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_RIGHT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_A]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_B]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_X]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_Y]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_L]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_R]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_START]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_SELECT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_HINGE]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_TOUCH_CURSOR_UP]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_TOUCH_CURSOR_DOWN]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_TOUCH_CURSOR_LEFT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_TOUCH_CURSOR_RIGHT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_TOUCH_CURSOR_PRESS]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_MENU]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_SAVE_STATE]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_LOAD_STATE]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_FAST_FORWARD]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_SWAP_SCREENS]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_SWAP_ORIENTATION_A]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_SWAP_ORIENTATION_B]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_LOAD_GAME]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_QUIT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_FAKE_MICROPHONE]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_UP]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_DOWN]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_LEFT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_RIGHT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_SELECT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_BACK]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_EXIT]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_PAGE_UP]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_PAGE_DOWN]': _DEFAULT_CODE,
+ 'controls_b[CONTROL_INDEX_UI_SWITCH]': _DEFAULT_CODE,
+}
+
+
+def _button_or_hat_value(inputs: InputDict, input_name: str, /) -> str:
+ if (input := inputs.get(input_name)) is None:
+ return _DEFAULT_CODE
+
+ if input.type == 'button':
+ return str(1024 + int(input.id))
+
+ if input.type == 'hat':
+ return str(1088 + _HAT_MASKS.get(input.name, 0))
+
+ return _DEFAULT_CODE
+
+
+def _read_existing_config(config_file: Path, /) -> dict[str, str]:
+ config: dict[str, str] = {}
+
+ if not config_file.exists():
+ return config
+
+ try:
+ with config_file.open('r', encoding='ascii', errors='ignore') as conf_file:
+ for line in conf_file:
+ line = line.strip()
+ if not line or line.startswith('#') or '=' not in line:
+ continue
+ key, value = line.split('=', 1)
+ config[key.strip()] = value.strip()
+ except OSError:
+ pass
+
+ return config
+
+
+def _controls_b_from_inputs(inputs: InputDict, /) -> dict[str, str]:
+ mappings = dict(_CONTROLS_B_UNBOUND)
+
+ mappings['controls_b[CONTROL_INDEX_UP]'] = _button_or_hat_value(inputs, 'up')
+ mappings['controls_b[CONTROL_INDEX_DOWN]'] = _button_or_hat_value(inputs, 'down')
+ mappings['controls_b[CONTROL_INDEX_LEFT]'] = _button_or_hat_value(inputs, 'left')
+ mappings['controls_b[CONTROL_INDEX_RIGHT]'] = _button_or_hat_value(inputs, 'right')
+ mappings['controls_b[CONTROL_INDEX_A]'] = _button_or_hat_value(inputs, 'a')
+ mappings['controls_b[CONTROL_INDEX_B]'] = _button_or_hat_value(inputs, 'b')
+ mappings['controls_b[CONTROL_INDEX_X]'] = _button_or_hat_value(inputs, 'x')
+ mappings['controls_b[CONTROL_INDEX_Y]'] = _button_or_hat_value(inputs, 'y')
+ mappings['controls_b[CONTROL_INDEX_L]'] = _button_or_hat_value(inputs, 'l2')
+ mappings['controls_b[CONTROL_INDEX_R]'] = _button_or_hat_value(inputs, 'r2')
+ mappings['controls_b[CONTROL_INDEX_START]'] = _button_or_hat_value(inputs, 'start')
+ mappings['controls_b[CONTROL_INDEX_SELECT]'] = _button_or_hat_value(inputs, 'select')
+ mappings['controls_b[CONTROL_INDEX_SWAP_SCREENS]'] = _button_or_hat_value(inputs, 'pageup')
+ mappings['controls_b[CONTROL_INDEX_FAST_FORWARD]'] = _button_or_hat_value(inputs, 'pagedown')
+
+ if 'joystick1left' in inputs and 'joystick1up' in inputs:
+ x_axis_id = int(inputs['joystick1left'].id)
+ y_axis_id = int(inputs['joystick1up'].id)
+ mappings['controls_b[CONTROL_INDEX_TOUCH_CURSOR_LEFT]'] = str(1216 + x_axis_id)
+ mappings['controls_b[CONTROL_INDEX_TOUCH_CURSOR_RIGHT]'] = str(1152 + x_axis_id)
+ mappings['controls_b[CONTROL_INDEX_TOUCH_CURSOR_UP]'] = str(1216 + y_axis_id)
+ mappings['controls_b[CONTROL_INDEX_TOUCH_CURSOR_DOWN]'] = str(1152 + y_axis_id)
+
+ mappings['controls_b[CONTROL_INDEX_TOUCH_CURSOR_PRESS]'] = _button_or_hat_value(inputs, 'l3')
+
+ menu_val = _button_or_hat_value(inputs, 'r3')
+ if menu_val == _DEFAULT_CODE:
+ menu_val = _button_or_hat_value(inputs, 'hotkey')
+ if menu_val == _DEFAULT_CODE:
+ menu_val = _button_or_hat_value(inputs, 'select')
+ mappings['controls_b[CONTROL_INDEX_MENU]'] = menu_val
+
+ mappings['controls_b[CONTROL_INDEX_UI_UP]'] = mappings['controls_b[CONTROL_INDEX_UP]']
+ mappings['controls_b[CONTROL_INDEX_UI_DOWN]'] = mappings['controls_b[CONTROL_INDEX_DOWN]']
+ mappings['controls_b[CONTROL_INDEX_UI_LEFT]'] = mappings['controls_b[CONTROL_INDEX_LEFT]']
+ mappings['controls_b[CONTROL_INDEX_UI_RIGHT]'] = mappings['controls_b[CONTROL_INDEX_RIGHT]']
+ mappings['controls_b[CONTROL_INDEX_UI_SELECT]'] = mappings['controls_b[CONTROL_INDEX_A]']
+ mappings['controls_b[CONTROL_INDEX_UI_BACK]'] = mappings['controls_b[CONTROL_INDEX_X]']
+ mappings['controls_b[CONTROL_INDEX_UI_EXIT]'] = mappings['controls_b[CONTROL_INDEX_B]']
+ mappings['controls_b[CONTROL_INDEX_UI_PAGE_UP]'] = _button_or_hat_value(inputs, 'pagedown')
+ mappings['controls_b[CONTROL_INDEX_UI_PAGE_DOWN]'] = _button_or_hat_value(inputs, 'pageup')
+ mappings['controls_b[CONTROL_INDEX_UI_SWITCH]'] = mappings['controls_b[CONTROL_INDEX_Y]']
+
+ return mappings
+
+
+@cached_dataclass
+class Drastic(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'drastic',
+ 'keys': {
+ 'exit': 'KEY_ESC',
+ 'save_state': 'KEY_F5',
+ 'restore_state': 'KEY_F7',
+ 'menu': 'KEY_F1',
+ 'fastforward': 'KEY_TAB',
+ 'swap_screen': 'KEY_F2',
+ },
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.config_dir
+
+ async def configure(self) -> Command:
+ bin_path = self.config_dir / 'drastic'
+ config_file = self.config_dir / 'config' / 'drastic.cfg'
+
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ if not bin_path.exists():
+ shutil.copytree(_SHARE_DIR, self.config_dir, dirs_exist_ok=True)
+ bin_path.chmod(0o0775)
+
+ config = {
+ 'show_frame_counter': '0',
+ 'enable_sound': '1',
+ 'compress_savestates': '1',
+ 'savestate_snapshot': '1',
+ 'firmware.username': 'Batocera',
+ 'firmware.favorite_color': '11',
+ 'firmware.birthday_month': '11',
+ 'firmware.birthday_day': '25',
+ 'enable_cheats': '1',
+ 'rtc_system_time': '1',
+ 'use_rtc_custom_time': '0',
+ 'rtc_custom_time': '0',
+ 'frameskip_type': '0',
+ 'frameskip_value': '1',
+ 'safe_frameskip': '1',
+ 'disable_edge_marking': '1',
+ 'fix_main_2d_screen': '0',
+ 'hires_3d': '0',
+ 'threaded_3d': '0',
+ 'screen_orientation': '0',
+ 'screen_scaling': '0',
+ 'screen_swap': '0',
+ 'controls_a[CONTROL_INDEX_UP]': '338',
+ 'controls_a[CONTROL_INDEX_DOWN]': '337',
+ 'controls_a[CONTROL_INDEX_LEFT]': '336',
+ 'controls_a[CONTROL_INDEX_RIGHT]': '335',
+ 'controls_a[CONTROL_INDEX_A]': '32',
+ 'controls_a[CONTROL_INDEX_B]': '480',
+ 'controls_a[CONTROL_INDEX_X]': '122',
+ 'controls_a[CONTROL_INDEX_Y]': '120',
+ 'controls_a[CONTROL_INDEX_L]': '481',
+ 'controls_a[CONTROL_INDEX_R]': '99',
+ 'controls_a[CONTROL_INDEX_START]': '13',
+ 'controls_a[CONTROL_INDEX_SELECT]': '485',
+ 'controls_a[CONTROL_INDEX_HINGE]': '104',
+ 'controls_a[CONTROL_INDEX_TOUCH_CURSOR_UP]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_TOUCH_CURSOR_DOWN]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_TOUCH_CURSOR_LEFT]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_TOUCH_CURSOR_RIGHT]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_TOUCH_CURSOR_PRESS]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_MENU]': '109',
+ 'controls_a[CONTROL_INDEX_SAVE_STATE]': '318',
+ 'controls_a[CONTROL_INDEX_LOAD_STATE]': '320',
+ 'controls_a[CONTROL_INDEX_FAST_FORWARD]': '8',
+ 'controls_a[CONTROL_INDEX_SWAP_SCREENS]': '115',
+ 'controls_a[CONTROL_INDEX_SWAP_ORIENTATION_A]': '97',
+ 'controls_a[CONTROL_INDEX_SWAP_ORIENTATION_B]': '100',
+ 'controls_a[CONTROL_INDEX_LOAD_GAME]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_QUIT]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_FAKE_MICROPHONE]': _DEFAULT_CODE,
+ 'controls_a[CONTROL_INDEX_UI_UP]': '338',
+ 'controls_a[CONTROL_INDEX_UI_DOWN]': '337',
+ 'controls_a[CONTROL_INDEX_UI_LEFT]': '336',
+ 'controls_a[CONTROL_INDEX_UI_RIGHT]': '335',
+ 'controls_a[CONTROL_INDEX_UI_SELECT]': '13',
+ 'controls_a[CONTROL_INDEX_UI_BACK]': '8',
+ 'controls_a[CONTROL_INDEX_UI_EXIT]': '27',
+ 'controls_a[CONTROL_INDEX_UI_PAGE_UP]': '331',
+ 'controls_a[CONTROL_INDEX_UI_PAGE_DOWN]': '334',
+ 'controls_a[CONTROL_INDEX_UI_SWITCH]': '481',
+ 'firmware.language': str(_LANGUAGE_MAPPING.get(self.config.get_str('system.language', 'en_US'), 1)),
+ **_read_existing_config(config_file),
+ }
+
+ # Safe parsing for screen orientation configuration
+ orientation = self.config.get_str('drastic_screen_orientation', '0')
+ if not orientation or orientation in {'auto', 'none'}:
+ screen_orientation = '0'
+ else:
+ try:
+ screen_orientation = str(int(orientation))
+ except ValueError:
+ screen_orientation = '0'
+
+ # Enforce front-end menu settings
+ config.update(
+ {
+ 'frameskip_type': str(self.config.get_int('drastic_frameskip_type', 0)),
+ 'frameskip_value': str(self.config.get_int('drastic_frameskip_value', 1)),
+ 'fix_main_2d_screen': str(self.config.get_int('drastic_fix2d', 0)),
+ 'hires_3d': str(self.config.get_int('drastic_hires', 0)),
+ 'threaded_3d': str(self.config.get_int('drastic_threaded', 0)),
+ 'screen_orientation': screen_orientation,
+ }
+ )
+
+ # Generate Slot B controller mappings
+ config.update(_controls_b_from_inputs(self.controllers[0].inputs) if self.controllers else _CONTROLS_B_UNBOUND)
+
+ with config_file.open('w', encoding='ascii') as conf_file:
+ for key, value in config.items():
+ conf_file.write(f'{key} = {value}\n')
+
+ env: dict[str, str | Path] = {
+ 'LD_PRELOAD': '/usr/lib/libdrastouch.so',
+ 'SDL_TOUCH_MOUSE_EVENTS': '0',
+ }
+
+ if (shader := self.config.get_str('drastic_shader', 'none')) and shader != 'none':
+ env['DSHOOK_SHADER'] = shader
+
+ try:
+ mic_threshold = float(self.config.get_str('drastic_mic_threshold', '0.0'))
+ except TypeError, ValueError:
+ mic_threshold = 0.0
+
+ if mic_threshold > 0.0:
+ env['DSHOOK_MIC_THRESH'] = str(mic_threshold)
+
+ return Command([bin_path, self.rom], env=env)
diff --git a/python-src/batocera-launch-drastic/pyproject.toml b/python-src/batocera-launch-drastic/pyproject.toml
new file mode 100644
index 00000000000..ca5a0020d20
--- /dev/null
+++ b/python-src/batocera-launch-drastic/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-drastic"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+drastic = 'batocera_launch_drastic:Drastic'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_drastic"]
diff --git a/python-src/batocera-launch-fallout/Config.in b/python-src/batocera-launch-fallout/Config.in
new file mode 100644
index 00000000000..b3cf99b88d7
--- /dev/null
+++ b/python-src/batocera-launch-fallout/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_FALLOUT
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-fallout/batocera-launch-fallout.mk b/python-src/batocera-launch-fallout/batocera-launch-fallout.mk
new file mode 100644
index 00000000000..42ea410a80f
--- /dev/null
+++ b/python-src/batocera-launch-fallout/batocera-launch-fallout.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-fallout
+#
+################################################################################
+
+BATOCERA_LAUNCH_FALLOUT_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_FALLOUT_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/python-batocera-common/tests/__init__.py b/python-src/batocera-launch-fallout/batocera_launch_fallout/__init__.py
similarity index 100%
rename from python-src/python-batocera-common/tests/__init__.py
rename to python-src/batocera-launch-fallout/batocera_launch_fallout/__init__.py
diff --git a/python-src/batocera-launch-fallout/batocera_launch_fallout/base.py b/python-src/batocera-launch-fallout/batocera_launch_fallout/base.py
new file mode 100644
index 00000000000..b766d2e8498
--- /dev/null
+++ b/python-src/batocera-launch-fallout/batocera_launch_fallout/base.py
@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import ClassVar
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class FalloutBase(Emulator):
+ needs_sdl_game_controller_config = True
+
+ CONFIG_FILE_NAME: ClassVar[str]
+ INI_FILE_NAME: ClassVar[str]
+ EXE_NAME: ClassVar[str]
+
+ SOUND_PATH: ClassVar[str]
+ CONFIG_PREFIX: ClassVar[str]
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'fallout1' if self.system == 'fallout1-ce' else 'fallout2',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F6',
+ 'restore_state': 'KEY_F7',
+ },
+ }
+
+ @property
+ def execution_path(self) -> Path:
+ return self.roms_dir
+
+ @cached_property
+ def config_file_path(self) -> Path:
+ return self.config_dir / self.CONFIG_FILE_NAME
+
+ @cached_property
+ def src_config_file_path(self) -> Path:
+ return self.roms_dir / self.CONFIG_FILE_NAME
+
+ @cached_property
+ def ini_file_path(self) -> Path:
+ return self.config_dir / self.INI_FILE_NAME
+
+ @cached_property
+ def src_ini_file_path(self) -> Path:
+ return self.roms_dir / self.INI_FILE_NAME
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ def _modify_config_file(self, cfg: CaseSensitiveConfigParser, /) -> None:
+ pass
+
+ def _modify_ini_file(self, ini: CaseSensitiveConfigParser, /) -> None:
+ pass
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ ROM_BIN_FILE = self.roms_dir / self.EXE_NAME
+ SRC_BIN_FILE = Path('/usr/bin') / self.EXE_NAME
+
+ # Copy latest binary to the rom directory
+ if not ROM_BIN_FILE.exists() or SRC_BIN_FILE.stat().st_mtime > ROM_BIN_FILE.stat().st_mtime:
+ shutil.copy(SRC_BIN_FILE, ROM_BIN_FILE)
+
+ # Copy cfg file to the config directory
+ if not self.config_file_path.exists() and self.src_config_file_path.exists():
+ shutil.copy(self.src_config_file_path, self.config_file_path)
+
+ # Now copy the ini file to the config directory
+ if not self.ini_file_path.exists() and self.src_ini_file_path.exists():
+ shutil.copy(self.src_ini_file_path, self.ini_file_path)
+
+ ## Configure
+
+ ## CFG Configuration
+ cfg = CaseSensitiveConfigParser()
+ if self.config_file_path.exists():
+ cfg.read(self.config_file_path)
+
+ if not cfg.has_section('debug'):
+ cfg.add_section('debug')
+ if not cfg.has_section('preferences'):
+ cfg.add_section('preferences')
+ if not cfg.has_section('sound'):
+ cfg.add_section('sound')
+ if not cfg.has_section('system'):
+ cfg.add_section('system')
+
+ # fix linux path issues
+ cfg.set('sound', 'music_path1', self.SOUND_PATH)
+ cfg.set('sound', 'music_path2', self.SOUND_PATH)
+
+ self._modify_config_file(cfg)
+
+ cfg.set('preferences', 'game_difficulty', self.config.get(f'{self.CONFIG_PREFIX}_game_difficulty', '1'))
+ cfg.set('preferences', 'combat_difficulty', self.config.get(f'{self.CONFIG_PREFIX}_combat_difficulty', '1'))
+ cfg.set('preferences', 'violence_level', self.config.get(f'{self.CONFIG_PREFIX}_violence_level', '2'))
+ cfg.set('preferences', 'subtitles', self.config.get(f'{self.CONFIG_PREFIX}_subtitles', '0'))
+ cfg.set('system', 'language', self.config.get(f'{self.CONFIG_PREFIX}_language', 'english'))
+
+ with self.config_file_path.open('w') as configfile:
+ cfg.write(configfile)
+
+ ## INI Configuration
+ ini = CaseSensitiveConfigParser()
+ if self.ini_file_path.exists():
+ ini.read(self.ini_file_path)
+
+ # [MAIN]
+ if not ini.has_section('MAIN'):
+ ini.add_section('MAIN')
+
+ # Note: This will increase the minimum resolution to from 640x480 to 1280x960.
+ ini.set('MAIN', 'SCALE_2X', '1' if self.resolution.width >= 1280 and self.resolution.height >= 960 else '0')
+ ini.set('MAIN', 'SCR_WIDTH', f'{self.resolution.width}')
+ ini.set('MAIN', 'SCR_HEIGHT', f'{self.resolution.height}')
+
+ # fullscreen
+ ini.set('MAIN', 'WINDOWED', '0')
+
+ self._modify_ini_file(ini)
+
+ with self.ini_file_path.open('w') as configfile:
+ ini.write(configfile)
+
+ return Command([ROM_BIN_FILE])
diff --git a/python-src/batocera-launch-fallout/batocera_launch_fallout/fallout1.py b/python-src/batocera-launch-fallout/batocera_launch_fallout/fallout1.py
new file mode 100644
index 00000000000..e3cbb2cc9be
--- /dev/null
+++ b/python-src/batocera-launch-fallout/batocera_launch_fallout/fallout1.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS
+
+from .base import FalloutBase
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from batocera_common.configparser import CaseSensitiveConfigParser
+
+
+@cached_dataclass
+class Fallout1(FalloutBase):
+ CONFIG_FILE_NAME: ClassVar[str] = 'fallout.cfg'
+ INI_FILE_NAME: ClassVar[str] = 'f1_res.ini'
+ EXE_NAME: ClassVar[str] = 'fallout1-ce'
+
+ SOUND_PATH: ClassVar[str] = 'DATA/SOUND/MUSIC/'
+ CONFIG_PREFIX: ClassVar[str] = 'fout1'
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'fallout1'
+
+ def _modify_config_file(self, cfg: CaseSensitiveConfigParser, /) -> None:
+ cfg.set('system', 'critter_dat', 'CRITTER.DAT')
+ cfg.set('system', 'critter_patches', 'DATA')
+ cfg.set('system', 'master_dat', 'MASTER.DAT')
+ cfg.set('system', 'master_patches', 'DATA')
diff --git a/python-src/batocera-launch-fallout/batocera_launch_fallout/fallout2.py b/python-src/batocera-launch-fallout/batocera_launch_fallout/fallout2.py
new file mode 100644
index 00000000000..de68ca63d9a
--- /dev/null
+++ b/python-src/batocera-launch-fallout/batocera_launch_fallout/fallout2.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, ClassVar
+
+from batocera_common.dataclasses import cached_property
+from batocera_common.paths import CONFIGS
+
+from .base import FalloutBase
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from batocera_common.configparser import CaseSensitiveConfigParser
+
+
+@dataclass(slots=True)
+class Fallout2(FalloutBase):
+ CONFIG_FILE_NAME: ClassVar[str] = 'fallout2.cfg'
+ INI_FILE_NAME: ClassVar[str] = 'f2_res.ini'
+ EXE_NAME: ClassVar[str] = 'fallout2-ce'
+
+ SOUND_PATH: ClassVar[str] = 'sound/music/'
+ CONFIG_PREFIX: ClassVar[str] = 'fout2'
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'fallout2'
+
+ def _modify_ini_file(self, ini: CaseSensitiveConfigParser, /) -> None:
+ # fix path
+ ini.set('MAIN', 'f2_res_patches', 'data/')
diff --git a/python-src/batocera-launch-fallout/pyproject.toml b/python-src/batocera-launch-fallout/pyproject.toml
new file mode 100644
index 00000000000..a1661dba5be
--- /dev/null
+++ b/python-src/batocera-launch-fallout/pyproject.toml
@@ -0,0 +1,18 @@
+[project]
+name = "batocera-launch-fallout"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+fallout1-ce = 'batocera_launch_fallout.fallout1:Fallout1'
+fallout2-ce = 'batocera_launch_fallout.fallout2:Fallout2'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_fallout"]
diff --git a/python-src/batocera-launch-flycast/Config.in b/python-src/batocera-launch-flycast/Config.in
new file mode 100644
index 00000000000..2975adea7c1
--- /dev/null
+++ b/python-src/batocera-launch-flycast/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_FLYCAST
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-flycast/batocera-launch-flycast.mk b/python-src/batocera-launch-flycast/batocera-launch-flycast.mk
new file mode 100644
index 00000000000..310ea4cf899
--- /dev/null
+++ b/python-src/batocera-launch-flycast/batocera-launch-flycast.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-flycast
+#
+################################################################################
+
+BATOCERA_LAUNCH_FLYCAST_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_FLYCAST_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-flycast/batocera_launch_flycast/__init__.py b/python-src/batocera-launch-flycast/batocera_launch_flycast/__init__.py
new file mode 100644
index 00000000000..c00a4094c16
--- /dev/null
+++ b/python-src/batocera-launch-flycast/batocera_launch_flycast/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from batocera_launch_flycast.emulator import Flycast as Flycast
diff --git a/python-src/batocera-launch-flycast/batocera_launch_flycast/controllers.py b/python-src/batocera-launch-flycast/batocera_launch_flycast/controllers.py
new file mode 100644
index 00000000000..a3afd369f15
--- /dev/null
+++ b/python-src/batocera-launch-flycast/batocera_launch_flycast/controllers.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+import configparser
+import logging
+from typing import TYPE_CHECKING, Final, Literal
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from batocera_launch import Controller
+
+_logger = logging.getLogger(__name__)
+
+
+_FLYCAST_INPUT_MAPPINGS: Final = {
+ 'dreamcast': {
+ # Directions
+ # The DPAD can be an axis (for gpio sticks for example) or a hat
+ # We don't map DPad2
+ 'up': {'button': 'btn_dpad1_up', 'hat': 'btn_dpad1_up', 'axis': 'btn_dpad1_up'},
+ 'down': {'button': 'btn_dpad1_down', 'hat': 'btn_dpad1_down'},
+ 'left': {'button': 'btn_dpad1_left', 'hat': 'btn_dpad1_left', 'axis': 'btn_dpad1_left'},
+ 'right': {'button': 'btn_dpad1_right', 'hat': 'btn_dpad1_right'},
+ 'joystick1left': {'axis': 'btn_analog_left'},
+ 'joystick1up': {'axis': 'btn_analog_up'},
+ 'joystick2left': {'axis': 'axis2_left'},
+ 'joystick2up': {'axis': 'axis2_up'},
+ # Buttons
+ 'b': {'button': 'btn_a'},
+ 'a': {'button': 'btn_b'},
+ 'y': {'button': 'btn_x'},
+ 'x': {'button': 'btn_y'},
+ # Triggers
+ 'l2': {'axis': 'axis_trigger_left', 'button': 'btn_trigger_left'},
+ 'r2': {'axis': 'axis_trigger_right', 'button': 'btn_trigger_right'},
+ # System Buttons
+ 'start': {'button': 'btn_start'},
+ },
+ 'arcade': {
+ # Directions
+ 'up': {'button': 'btn_dpad1_up', 'hat': 'btn_dpad1_up', 'axis': 'btn_dpad1_up'},
+ 'down': {'button': 'btn_dpad1_down', 'hat': 'btn_dpad1_down'},
+ 'left': {'button': 'btn_dpad1_left', 'hat': 'btn_dpad1_left', 'axis': 'btn_dpad1_left'},
+ 'right': {'button': 'btn_dpad1_right', 'hat': 'btn_dpad1_right'},
+ 'joystick1left': {'axis': 'btn_analog_left'},
+ 'joystick1up': {'axis': 'btn_analog_up'},
+ 'joystick2left': {'axis': 'axis2_left'},
+ 'joystick2up': {'axis': 'axis2_up'},
+ # Buttons
+ 'b': {'button': 'btn_a'},
+ 'a': {'button': 'btn_b'},
+ 'y': {'button': 'btn_c'},
+ 'x': {'button': 'btn_x'},
+ 'pageup': {'button': 'btn_y'},
+ 'pagedown': {'button': 'btn_z'},
+ 'l3': {'button': 'btn_dpad2_up'},
+ 'r3': {'button': 'btn_dpad2_down'},
+ # Triggers
+ 'l2': {'axis': 'axis_trigger_left', 'button': 'btn_trigger_left'},
+ 'r2': {'axis': 'axis_trigger_right', 'button': 'btn_trigger_right'},
+ # System Buttons
+ 'start': {'button': 'btn_start'},
+ # coin
+ 'select': {'button': 'btn_d'},
+ },
+}
+
+_SECTIONS: Final = ('analog', 'digital', 'emulator')
+
+_HAT_CODE_MAPPING: Final = {
+ 'up': 256,
+ 'down': 257,
+ 'left': 258,
+ 'right': 259,
+}
+
+# Define digital bindings for keyboard
+_KEYBOARD_BINDINGS: Final = {
+ 'bind0': '4:btn_d',
+ 'bind1': '6:btn_b',
+ 'bind2': '7:btn_y',
+ 'bind3': '9:btn_trigger_left',
+ 'bind4': '12:btn_analog_up',
+ 'bind5': '13:btn_analog_left',
+ 'bind6': '14:btn_analog_down',
+ 'bind7': '15:btn_analog_right',
+ 'bind8': '22:btn_x',
+ 'bind9': '25:btn_trigger_right',
+ 'bind10': '27:btn_a',
+ 'bind11': '40:btn_start',
+ 'bind12': '43:btn_menu',
+ 'bind13': '44:btn_fforward',
+ 'bind14': '64:btn_escape',
+ 'bind15': '65:btn_jump_state',
+ 'bind16': '66:btn_quick_save',
+ 'bind17': '67:btn_screenshot',
+ 'bind18': '79:btn_dpad1_right',
+ 'bind19': '80:btn_dpad1_left',
+ 'bind20': '81:btn_dpad1_down',
+ 'bind21': '82:btn_dpad1_up',
+}
+
+
+# Create the controller configuration file
+def generate_controller_config(
+ mapping_dir: Path, controller: Controller, type: Literal['dreamcast', 'arcade'], /
+) -> None:
+ # Set config file name
+ if type == 'dreamcast':
+ _logger.debug('-=[ Dreamcast Controller Settings ]=-')
+ config_file = mapping_dir / f'SDL_{controller.real_name}.cfg'
+ else: # 'arcade'
+ _logger.debug('-=[ Arcade Controller Settings ]=-')
+ config_file = mapping_dir / f'SDL_{controller.real_name}_arcade.cfg'
+
+ config = configparser.ConfigParser(interpolation=None)
+
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ # create ini sections
+ for section in _SECTIONS:
+ config.add_section(section)
+
+ # Parse controller inputs
+ _logger.debug('*** Controller Name = %s ***', controller.real_name)
+ analog_bind = 0
+ digital_bind = 0
+ input_mapping = _FLYCAST_INPUT_MAPPINGS[type]
+
+ for input in controller.inputs.values():
+ if input.name not in input_mapping:
+ continue
+
+ if input.type not in input_mapping[input.name]:
+ _logger.debug('Input type: %s / %s - not in mapping', input.type, input.name)
+ continue
+
+ var = input_mapping[input.name][input.type]
+ _logger.debug('Input Name = %s, Var = %s, Type = %s', input.name, var, input.type)
+ # batocera doesn't retrieve the code for hats, however
+ # SDL is 256 for up, 257 for down, 258 for left & 259 for right
+ if input.type == 'hat':
+ code = _HAT_CODE_MAPPING[input.name]
+ option = f'bind{digital_bind}'
+ digital_bind = digital_bind + 1
+ config.set('digital', option, f'{code}:{var}')
+
+ elif input.type == 'button':
+ option = f'bind{digital_bind}'
+ digital_bind = digital_bind + 1
+ code = input.id
+ config.set('digital', option, f'{code}:{var}')
+
+ elif input.type == 'axis':
+ section = 'analog'
+
+ if input.name == 'l2' or input.name == 'r2':
+ # Use positive axis for full trigger control
+ code = f'{input.id}+'
+ else:
+ code = f'{input.id}-'
+
+ option = f'bind{analog_bind}'
+ analog_bind = analog_bind + 1
+
+ if 'left' in input.name or 'up' in input.name:
+ config.set(section, option, f'{code}:{var}')
+ # becase we only take one axis input
+ # now have to write the joy-right & joy-down manually
+ # we use the same code number but positive axis
+ option = f'bind{analog_bind}'
+ analog_bind = analog_bind + 1
+
+ if input.name == 'joystick1left':
+ code = f'{input.id}+'
+ var = 'btn_analog_right'
+ config.set(section, option, f'{code}:{var}')
+ elif input.name == 'joystick1up':
+ code = f'{input.id}+'
+ var = 'btn_analog_down'
+ config.set(section, option, f'{code}:{var}')
+ elif input.name == 'joystick2left':
+ code = f'{input.id}+'
+ var = 'axis2_right'
+ config.set(section, option, f'{code}:{var}')
+ elif input.name == 'joystick2up':
+ code = f'{input.id}+'
+ var = 'axis2_down'
+ config.set(section, option, f'{code}:{var}')
+ elif input.name == 'up':
+ code = f'{input.id}+'
+ var = 'btn_dpad1_down'
+ config.set(section, option, f'{code}:{var}')
+ elif input.name == 'left':
+ code = f'{input.id}+'
+ var = 'btn_dpad1_right'
+ config.set(section, option, f'{code}:{var}')
+ else:
+ config.set(section, option, f'{code}:{var}')
+
+ # Add additional controller info
+ config.set('emulator', 'dead_zone', '10')
+ config.set('emulator', 'mapping_name', 'Default') # controller.real_name)
+ config.set('emulator', 'rumble_power', '100')
+ config.set('emulator', 'version', '3')
+
+ with config_file.open('w') as f:
+ config.write(f)
+
+
+def generate_keyboard_config(mapping_dir: Path, /) -> None:
+ config_file = mapping_dir / 'SDL_Keyboard.cfg'
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ config = configparser.ConfigParser(interpolation=None)
+
+ # Add cfg sections
+ config.add_section('digital')
+ config.add_section('emulator')
+
+ # Set each binding in the config
+ for bind, val in _KEYBOARD_BINDINGS.items():
+ config.set('digital', bind, val)
+
+ # Add the additional keyboard info to the emulator section
+ config.set('emulator', 'dead_zone', '10')
+ config.set('emulator', 'mapping_name', 'Keyboard')
+ config.set('emulator', 'rumble_power', '100')
+ config.set('emulator', 'saturation', '100')
+ config.set('emulator', 'version', '3')
+
+ with config_file.open('w') as f:
+ config.write(f)
diff --git a/package/batocera/core/batocera-configgen/data/dreamcast/vmu_save_blank.bin b/python-src/batocera-launch-flycast/batocera_launch_flycast/data/vmu_save_blank.bin
similarity index 100%
rename from package/batocera/core/batocera-configgen/data/dreamcast/vmu_save_blank.bin
rename to python-src/batocera-launch-flycast/batocera_launch_flycast/data/vmu_save_blank.bin
diff --git a/python-src/batocera-launch-flycast/batocera_launch_flycast/emulator.py b/python-src/batocera-launch-flycast/batocera_launch_flycast/emulator.py
new file mode 100644
index 00000000000..5cff4f0aef1
--- /dev/null
+++ b/python-src/batocera-launch-flycast/batocera_launch_flycast/emulator.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+from importlib import resources
+from shutil import copyfile
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS, CONFIGS, SAVES
+from batocera_launch import (
+ Command,
+ Emulator,
+ HotkeysContext,
+)
+
+from .controllers import generate_controller_config, generate_keyboard_config
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+VMU_BLANK: Final = resources.files() / 'data' / 'vmu_save_blank.bin'
+
+
+@cached_dataclass
+class Flycast(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'flycast',
+ 'keys': {
+ 'exit': 'KEY_F7',
+ 'menu': 'KEY_TAB',
+ 'save_state': 'KEY_F8',
+ 'restore_state': 'KEY_F9',
+ 'fastforward': 'KEY_SPACE',
+ },
+ }
+
+ @cached_property
+ def bios_dir(self) -> Path:
+ return BIOS / 'dc'
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / 'dreamcast' / 'flycast'
+
+ @cached_property
+ def vmua1_path(self) -> Path:
+ return self.saves_dir / 'vmu_save_A1.bin'
+
+ @cached_property
+ def vmua2_path(self) -> Path:
+ return self.saves_dir / 'vmu_save_A2.bin'
+
+ @cached_property
+ def mapping_dir(self) -> Path:
+ return self.config_dir / 'mappings'
+
+ async def configure(self) -> Command:
+ config = CaseSensitiveConfigParser(interpolation=None)
+ config_path = self.config_dir / 'emu.cfg'
+
+ if config_path.exists():
+ try:
+ config.read(config_path)
+ except Exception:
+ pass # give up the file
+
+ if not config.has_section('input'):
+ config.add_section('input')
+
+ # Lightguns - configure before controllers (guns take priority on ports)
+ gun_ports: set[int] = set()
+ if self.config.use_guns and self.guns:
+ for player_number, _ in enumerate(self.guns[:4], start=1):
+ config.set('input', f'device{player_number}', '7') # MDT_LightGun
+ config.set('input', f'device{player_number}.1', '1') # VMU
+ config.set('input', f'device{player_number}.2', '10') # None
+ gun_ports.add(player_number)
+
+ # For each pad detected
+ for controller in self.controllers:
+ # Skip ports already assigned to guns
+ if controller.player_number in gun_ports:
+ continue
+
+ # Write the mapping files for Dreamcast
+ if self.system == 'dreamcast':
+ generate_controller_config(self.mapping_dir, controller, 'dreamcast')
+ else:
+ # Write the Arcade variant (Atomiswave & Naomi/2)
+ generate_controller_config(self.mapping_dir, controller, 'arcade')
+
+ # Set the controller type per Port
+ config.set('input', f'device{controller.player_number}', '0') # Sega Controller
+ config.set('input', f'device{controller.player_number}.1', '1') # Sega VMU
+ # Set controller pack, gui option
+ config.set(
+ 'input',
+ f'device{controller.player_number}.2',
+ self.config.get_str(f'flycast_ctrl{controller.player_number}_pack', '1'),
+ ) # Sega VMU
+ # Ensure controller(s) are on seperate Ports
+ port = controller.player_number - 1
+ config.set('input', f'maple_sdl_joystick_{controller.index}', str(port))
+
+ # add the keyboard mappings for hotkeys
+ generate_keyboard_config(self.mapping_dir)
+
+ if not config.has_section('config'):
+ config.add_section('config')
+ if not config.has_section('window'):
+ config.add_section('window')
+ # ensure we are always fullscreen
+ config.set('window', 'fullscreen', 'yes')
+ # set video resolution
+ config.set('window', 'width', str(self.resolution.width))
+ config.set('window', 'height', str(self.resolution.height))
+ # set render resolution - default 480 (Native)
+ config.set('config', 'rend.Resolution', self.config.get_str('flycast_render_resolution', '480'))
+ # wide screen mode - default off
+ config.set('config', 'rend.WideScreen', self.config.get_str('flycast_ratio', 'no'))
+ # rotate option - default off
+ config.set('config', 'rend.Rotate90', self.config.get_str('flycast_rotate', 'no'))
+ # renderer - default: OpenGL
+ renderer = self.config.get('flycast_renderer')
+ sorting = self.config.get('flycast_sorting')
+
+ if renderer == '0':
+ if sorting == '3':
+ # per pixel
+ config.set('config', 'pvr.rend', '3')
+ else:
+ # per triangle
+ config.set('config', 'pvr.rend', '0')
+ elif renderer == '4':
+ if sorting == '3':
+ # per pixel
+ config.set('config', 'pvr.rend', '5')
+ else:
+ # per triangle
+ config.set('config', 'pvr.rend', '4')
+ else:
+ config.set('config', 'pvr.rend', '0')
+ if sorting == '3':
+ # per pixel
+ config.set('config', 'pvr.rend', '3')
+
+ # anisotropic filtering
+ config.set('config', 'rend.AnisotropicFiltering', self.config.get_str('flycast_anisotropic', '1'))
+ # transparent sorting
+ # per strip
+ config.set('config', 'rend.PerStripSorting', 'yes' if self.config.get('flycast_sorting') == '2' else 'no')
+
+ # [Dreamcast specifics]
+ # language
+ config.set('config', 'Dreamcast.Language', self.config.get_str('flycast_language', '1'))
+ # region
+ config.set('config', 'Dreamcast.Region', self.config.get_str('flycast_region', '1'))
+ # save / load states
+ config.set('config', 'Dreamcast.AutoLoadState', self.config.get_str('flycast_loadstate', 'no'))
+ config.set('config', 'Dreamcast.AutoSaveState', self.config.get_str('flycast_savestate', 'no'))
+ # windows CE
+ config.set('config', 'Dreamcast.ForceWindowsCE', self.config.get_str('flycast_winCE', 'no'))
+ # Per-game VMU
+ config.set('config', 'PerGameVmu', self.config.get_bool('flycast_per_game_vmu', return_values=('yes', 'no')))
+ # DSP
+ config.set('config', 'aica.DSPEnabled', self.config.get_str('flycast_DSP', 'no'))
+ # Guns crosshairs
+ config.set('config', 'rend.CrossHairColor1', self.config.get_str('flycast_lightgun1_crosshair', '0'))
+ config.set('config', 'rend.CrossHairColor2', self.config.get_str('flycast_lightgun2_crosshair', '0'))
+ config.set('config', 'rend.CrossHairColor3', self.config.get_str('flycast_lightgun3_crosshair', '0'))
+ config.set('config', 'rend.CrossHairColor4', self.config.get_str('flycast_lightgun4_crosshair', '0'))
+
+ # Retroachievements
+ if not config.has_section('achievements'):
+ config.add_section('achievements')
+
+ if self.config.get_bool('retroachievements'):
+ username = self.config.get('retroachievements.username', '')
+ hardcore = self.config.get_bool('retroachievements.hardcore', return_values=('yes', 'no'))
+ token = self.config.get('retroachievements.token', '')
+
+ # apply config
+ config.set('achievements', 'Enabled', 'yes')
+ config.set('achievements', 'HardcoreMode', hardcore)
+ config.set('achievements', 'Token', token)
+ config.set('achievements', 'UserName', username)
+ else:
+ config.set('achievements', 'Enabled', 'no')
+
+ # custom : allow the user to configure directly emu.cfg via batocera.conf via lines like : dreamcast.flycast.section.option=value
+ for section_option, user_config_value in self.config.items(starts_with='flycast.'):
+ custom_section, _, custom_option = section_option.partition('.')
+ if not config.has_section(custom_section):
+ config.add_section(custom_section)
+ config.set(custom_section, custom_option, user_config_value)
+
+ ### update the configuration file
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ with config_path.open('w+') as cfg_file:
+ config.write(cfg_file)
+
+ # internal config
+ self.saves_dir.mkdir(parents=True, exist_ok=True)
+
+ if not self.vmua1_path.is_file() or not self.vmua2_path.is_file():
+ with resources.as_file(VMU_BLANK) as vmu_blank:
+ # vmuA1
+ if not self.vmua1_path.is_file():
+ copyfile(vmu_blank, self.vmua1_path)
+
+ # vmuA2
+ if not self.vmua2_path.is_file():
+ copyfile(vmu_blank, self.vmua2_path)
+
+ # Here is the trick to make flycast find files :
+ # emu.cfg is in $XDG_CONFIG_DIRS or $XDG_CONFIG_HOME.
+ # VMU will be in $XDG_DATA_HOME / $FLYCAST_DATADIR because it needs rw access -> /userdata/saves/dreamcast
+ # $FLYCAST_BIOS_PATH is where Flaycast should find the bios files
+ # controller cfg files are set with an absolute path, so no worry
+ return Command(
+ ['/usr/bin/flycast', self.rom],
+ {
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_CONFIG_DIRS': CONFIGS,
+ 'XDG_DATA_HOME': self.saves_dir.parent,
+ 'FLYCAST_DATADIR': self.saves_dir.parent,
+ 'FLYCAST_BIOS_PATH': self.bios_dir,
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch-flycast/batocera_launch_flycast/py.typed b/python-src/batocera-launch-flycast/batocera_launch_flycast/py.typed
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/python-src/batocera-launch-flycast/pyproject.toml b/python-src/batocera-launch-flycast/pyproject.toml
new file mode 100644
index 00000000000..ecc9f602b4b
--- /dev/null
+++ b/python-src/batocera-launch-flycast/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-flycast"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+flycast = 'batocera_launch_flycast:Flycast'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_flycast"]
diff --git a/python-src/batocera-launch-kodi/Config.in b/python-src/batocera-launch-kodi/Config.in
new file mode 100644
index 00000000000..03092d388f5
--- /dev/null
+++ b/python-src/batocera-launch-kodi/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_KODI
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-kodi/batocera-launch-kodi.mk b/python-src/batocera-launch-kodi/batocera-launch-kodi.mk
new file mode 100644
index 00000000000..8754c57396b
--- /dev/null
+++ b/python-src/batocera-launch-kodi/batocera-launch-kodi.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-kodi
+#
+################################################################################
+
+BATOCERA_LAUNCH_KODI_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_KODI_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-kodi/batocera_launch_kodi/__init__.py b/python-src/batocera-launch-kodi/batocera_launch_kodi/__init__.py
new file mode 100644
index 00000000000..1689a9683df
--- /dev/null
+++ b/python-src/batocera-launch-kodi/batocera_launch_kodi/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import Kodi as Kodi
diff --git a/python-src/batocera-launch-kodi/batocera_launch_kodi/config.py b/python-src/batocera-launch-kodi/batocera_launch_kodi/config.py
new file mode 100644
index 00000000000..84f565ee460
--- /dev/null
+++ b/python-src/batocera-launch-kodi/batocera_launch_kodi/config.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+import hashlib
+from typing import TYPE_CHECKING, Final
+from xml.dom import minidom
+
+from batocera_common.paths import HOME
+
+if TYPE_CHECKING:
+ from batocera_launch import Controllers
+
+_KODI_USERDATA: Final = HOME / '.kodi' / 'userdata'
+_HAT_POSITIONS: Final = {1: 'up', 2: 'right', 4: 'down', 8: 'left'}
+_REVERSE_POSITIONS: Final = {
+ 'joystick1up': 'joystick1down',
+ 'joystick1left': 'joystick1right',
+ 'joystick2up': 'joystick2down',
+ 'joystick2left': 'joystick2right',
+}
+_BUTTON_MAPPING: Final = {
+ 'a': 'b',
+ 'b': 'a',
+ 'x': 'y',
+ 'y': 'x',
+ 'hotkey': 'guide',
+ 'select': 'back',
+ 'start': 'start',
+ 'pageup': 'leftbumper',
+ 'l2': 'lefttrigger',
+ 'pagedown': 'rightbumper',
+ 'r2': 'righttrigger',
+ 'up': 'up',
+ 'down': 'down',
+ 'left': 'left',
+ 'right': 'right',
+}
+_AXIS_MAPPING: Final = {
+ 'joystick1up': {'name': 'leftstick', 'sens': 'up'},
+ 'joystick1down': {'name': 'leftstick', 'sens': 'down'},
+ 'joystick1left': {'name': 'leftstick', 'sens': 'left'},
+ 'joystick1right': {'name': 'leftstick', 'sens': 'right'},
+ 'joystick2up': {'name': 'rightstick', 'sens': 'up'},
+ 'joystick2down': {'name': 'rightstick', 'sens': 'down'},
+ 'joystick2left': {'name': 'rightstick', 'sens': 'left'},
+ 'joystick2right': {'name': 'rightstick', 'sens': 'right'},
+}
+
+
+def _vidpid(guid: str) -> tuple[str, str]:
+ return guid[10:12] + guid[8:10], guid[18:20] + guid[16:18]
+
+
+def write_kodi_config(controllers: Controllers) -> None:
+ # if there is no controller, don't remove the current generated one
+ # it allows people to start kodi at startup when having only bluetooth joysticks
+ # or this allows people to plug the last used joystick
+ if not controllers:
+ return
+
+ provider = 'udev'
+ buttonmap_dir = (
+ _KODI_USERDATA / 'addon_data' / 'peripheral.joystick' / 'resources' / 'buttonmaps' / 'xml' / provider
+ )
+ buttonmap_dir.mkdir(parents=True, exist_ok=True)
+
+ done: set[str] = set()
+ for controller in controllers:
+ if controller.real_name in done:
+ continue
+ done.add(controller.real_name)
+
+ name_hash = hashlib.md5(controller.real_name.encode(), usedforsecurity=False).hexdigest()
+ config_path = buttonmap_dir / f'batocera_{controller.guid}_{name_hash}.xml'
+
+ document = minidom.Document()
+ xml_buttonmap = document.createElement('buttonmap')
+ document.appendChild(xml_buttonmap)
+
+ xml_device = document.createElement('device')
+ xml_device.setAttribute('name', controller.real_name)
+ xml_device.setAttribute('provider', provider)
+ if provider == 'udev':
+ vid, pid = _vidpid(controller.guid)
+ xml_device.setAttribute('vid', vid)
+ xml_device.setAttribute('pid', pid)
+ xml_device.setAttribute('buttoncount', str(controller.button_count))
+ xml_device.setAttribute('axiscount', str(2 * controller.hat_count + controller.axis_count))
+ xml_buttonmap.appendChild(xml_device)
+
+ xml_controller = document.createElement('controller')
+ xml_controller.setAttribute('id', 'game.controller.default')
+
+ sticks_node: dict[str, minidom.Element] = {}
+ already_set: set[str] = set()
+
+ for controller_input in controller.inputs.values():
+ if controller_input.type == 'axis' and controller_input.name in _AXIS_MAPPING:
+ mapping = _AXIS_MAPPING[controller_input.name]
+ if mapping['name'] not in sticks_node:
+ sticks_node[mapping['name']] = document.createElement('feature')
+ sticks_node[mapping['name']].setAttribute('name', mapping['name'])
+ for sens in [controller_input.name, _REVERSE_POSITIONS[controller_input.name]]:
+ xml_sens = document.createElement(_AXIS_MAPPING[sens]['sens'])
+ val = controller_input.id
+ if (int(controller_input.value) >= 0 and sens == controller_input.name) or (
+ int(controller_input.value) < 0 and sens != controller_input.name
+ ):
+ val = f'+{val}'
+ else:
+ val = f'-{val}'
+ xml_sens.setAttribute('axis', val)
+ sticks_node[_AXIS_MAPPING[sens]['name']].appendChild(xml_sens)
+ elif controller_input.name in _BUTTON_MAPPING:
+ if controller_input.type == 'button':
+ btn_key = f'btn_{int(controller_input.id)}'
+ if btn_key not in already_set:
+ xml_button = document.createElement('feature')
+ xml_button.setAttribute('name', _BUTTON_MAPPING[controller_input.name])
+ xml_button.setAttribute('button', str(int(controller_input.id)))
+ xml_controller.appendChild(xml_button)
+ already_set.add(btn_key)
+ elif controller_input.type == 'hat' and int(controller_input.value) in _HAT_POSITIONS:
+ xml_hat = document.createElement('feature')
+ hat_name = _HAT_POSITIONS[int(controller_input.value)]
+ val = str(controller.axis_count if hat_name in {'left', 'right'} else controller.axis_count + 1)
+ xml_hat.setAttribute('axis', f'+{val}' if hat_name in {'down', 'right'} else f'-{val}')
+ xml_hat.setAttribute('name', hat_name)
+ xml_controller.appendChild(xml_hat)
+ elif controller_input.type == 'axis':
+ xml_axis = document.createElement('feature')
+ val = controller_input.id
+ val = f'+{val}' if int(controller_input.value) >= 0 else f'-{val}'
+ xml_axis.setAttribute('axis', val)
+ xml_axis.setAttribute('name', _BUTTON_MAPPING[controller_input.name])
+ xml_controller.appendChild(xml_axis)
+
+ for node in sticks_node.values():
+ xml_controller.appendChild(node)
+ xml_device.appendChild(xml_controller)
+ config_path.write_text(document.toprettyxml())
+
+ settings_path = _KODI_USERDATA / 'addon_data' / 'peripheral.joystick' / 'settings.xml'
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
+ settings_path.write_text('1')
+
+ # disable the kodi splash by default (nicer integration)
+ advxml = _KODI_USERDATA / 'advancedsettings.xml'
+ if not advxml.exists():
+ advxml.parent.mkdir(parents=True, exist_ok=True)
+ advxml.write_text('false')
diff --git a/python-src/batocera-launch-kodi/batocera_launch_kodi/emulator.py b/python-src/batocera-launch-kodi/batocera_launch_kodi/emulator.py
new file mode 100644
index 00000000000..f18c3cf31d4
--- /dev/null
+++ b/python-src/batocera-launch-kodi/batocera_launch_kodi/emulator.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+from .config import write_kodi_config
+
+
+@cached_dataclass
+class Kodi(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'kodi',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return self.resolution.width / self.resolution.height
+
+ async def configure(self) -> Command:
+ write_kodi_config(self.controllers)
+ return Command(['/usr/bin/batocera-kodilauncher'])
diff --git a/python-src/batocera-launch-kodi/pyproject.toml b/python-src/batocera-launch-kodi/pyproject.toml
new file mode 100644
index 00000000000..452d56eb141
--- /dev/null
+++ b/python-src/batocera-launch-kodi/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-kodi"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+kodi = 'batocera_launch_kodi:Kodi'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_kodi"]
diff --git a/python-src/batocera-launch-openjazz/Config.in b/python-src/batocera-launch-openjazz/Config.in
new file mode 100644
index 00000000000..2496a4d461d
--- /dev/null
+++ b/python-src/batocera-launch-openjazz/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_OPENJAZZ
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-openjazz/batocera-launch-openjazz.mk b/python-src/batocera-launch-openjazz/batocera-launch-openjazz.mk
new file mode 100644
index 00000000000..fe1e3b91ce9
--- /dev/null
+++ b/python-src/batocera-launch-openjazz/batocera-launch-openjazz.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-openjazz
+#
+################################################################################
+
+BATOCERA_LAUNCH_OPENJAZZ_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_OPENJAZZ_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-openjazz/batocera_launch_openjazz/__init__.py b/python-src/batocera-launch-openjazz/batocera_launch_openjazz/__init__.py
new file mode 100644
index 00000000000..ecba4c71c3c
--- /dev/null
+++ b/python-src/batocera-launch-openjazz/batocera_launch_openjazz/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import OpenJazz as OpenJazz
diff --git a/python-src/batocera-launch-openjazz/batocera_launch_openjazz/emulator.py b/python-src/batocera-launch-openjazz/batocera_launch_openjazz/emulator.py
new file mode 100644
index 00000000000..aae3d382504
--- /dev/null
+++ b/python-src/batocera-launch-openjazz/batocera_launch_openjazz/emulator.py
@@ -0,0 +1,352 @@
+from __future__ import annotations
+
+import logging
+import struct
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Final, Self
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CACHE, CONFIGS, ROMS, SAVES
+from batocera_launch import Command, Controller, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+ from typing import BinaryIO
+
+_logger = logging.getLogger(__name__)
+
+_STRING_LENGTH: Final = 32 # Max character length for `characterName`
+_CONTROLS: Final = 19 # Number of control settings
+_ROM_DIR: Final = ROMS / 'openjazz'
+
+_BUTTON_SLOTS: Final = {
+ 'a': 4, # Jump
+ 'b': 6, # Fire
+ 'x': 5, # Swim
+ 'y': 7, # Change
+ 'select': 9, # Escape
+ 'start': 8, # Enter
+}
+
+_char = struct.Struct('B')
+_short = struct.Struct(' int:
+ return _char.unpack(reader.read(1))[0]
+
+
+def _write_char(writer: BinaryIO, data: int, /) -> None:
+ writer.write(_char.pack(data))
+
+
+def _read_short(reader: BinaryIO) -> int:
+ return _short.unpack(reader.read(2))[0]
+
+
+def _write_short(writer: BinaryIO, data: int, /) -> None:
+ writer.write(_short.pack(data))
+
+
+def _read_int(reader: BinaryIO) -> int:
+ return _int.unpack(reader.read(4))[0]
+
+
+def _write_int(writer: BinaryIO, data: int, /) -> None:
+ writer.write(_int.pack(data))
+
+
+@dataclass(slots=True)
+class _OpenJazzConfig:
+ path: Path
+ version: int = 7
+ video_width: int = 640
+ video_height: int = 480
+ fullscreen: bool = True
+ video_scale: int = 1
+ scale_method: int = 0
+ keys: list[int] = field(
+ default_factory=lambda: [
+ 1073741906, # Up
+ 1073741905, # Down
+ 1073741904, # Left
+ 1073741903, # Right
+ 32, # Jump
+ 32, # Swim
+ 1073742050, # Fire
+ 1073742052, # Change
+ 13, # Enter
+ 27, # Escape
+ 49, # Blaster
+ 50, # Toaster
+ 51, # Missle
+ 52, # Bouncer
+ 53, # TNT
+ ]
+ )
+ buttons: list[int] = field(
+ default_factory=lambda: [
+ -1, # Up
+ -1, # Down
+ -1, # Left
+ -1, # Right
+ 2, # Jump
+ 4, # Swim
+ 1, # Fire
+ 3, # Change
+ 8, # Enter
+ 7, # Escape
+ -1, # Blaster
+ -1, # Toaster
+ -1, # Missle
+ -1, # Bouncer
+ -1, # TNT
+ -1, # Stats
+ 4, # Pause
+ -1, # Yes
+ -1, # No
+ ]
+ )
+ axes: list[tuple[int, int]] = field(
+ default_factory=lambda: [
+ (1, 0), # Up
+ (1, 1), # Down
+ (0, 0), # Left
+ (0, 1), # Right
+ (-1, 0), # Jump
+ (-1, 0), # Swim
+ (-1, 0), # Fire
+ (-1, 0), # Change
+ (-1, 0), # Enter
+ (-1, 0), # Escape
+ (-1, 0), # Blaster
+ (-1, 0), # Toaster
+ (-1, 0), # Missle
+ (-1, 0), # Bouncer
+ (-1, 0), # TNT
+ (-1, 0), # Stats
+ (-1, 0), # Pause
+ (-1, 0), # Yes
+ (-1, 0), # No
+ ]
+ )
+ hats: list[tuple[int, int]] = field(
+ default_factory=lambda: [
+ (0, 1), # Up
+ (0, 4), # Down
+ (0, 8), # Left
+ (0, 2), # Right
+ (-1, 0), # Jump
+ (-1, 0), # Swim
+ (-1, 0), # Fire
+ (-1, 0), # Change
+ (-1, 0), # Enter
+ (-1, 0), # Escape
+ (-1, 0), # Blaster
+ (-1, 0), # Toaster
+ (-1, 0), # Missle
+ (-1, 0), # Bouncer
+ (-1, 0), # TNT
+ (-1, 0), # Stats
+ (-1, 0), # Pause
+ (-1, 0), # Yes
+ (-1, 0), # No
+ ]
+ )
+ character_name: str = 'Jazz'
+ character_colors: list[int] = field(default_factory=lambda: [255, 255, 255, 255])
+ music_volume: int = 255
+ sound_volume: int = 255
+ many_birds: bool = False
+ leave_unneeded: bool = False
+ slow_motion: bool = False
+ hud_fps: bool = False
+
+ def save(self) -> None:
+ _logger.info('Saving configuration')
+ try:
+ with self.path.open('wb') as f:
+ _write_char(f, 7)
+
+ _write_short(f, self.video_width)
+ _write_short(f, self.video_height)
+ _write_char(f, self.fullscreen)
+ _write_char(f, self.video_scale)
+ _write_char(f, self.scale_method)
+
+ for key in self.keys:
+ _write_int(f, key)
+
+ for button in self.buttons:
+ _write_int(f, button)
+
+ for axis, direction in self.axes:
+ _write_int(f, axis)
+ _write_int(f, direction)
+
+ for hat, direction in self.hats:
+ _write_int(f, hat)
+ _write_int(f, direction)
+
+ name_bytes = self.character_name.encode('utf-8')[:_STRING_LENGTH].ljust(_STRING_LENGTH, b'\x00')
+ f.write(name_bytes)
+
+ for color in self.character_colors:
+ _write_char(f, color)
+
+ _write_char(f, self.music_volume)
+ _write_char(f, self.sound_volume)
+ _write_char(
+ f,
+ self.many_birds | (self.leave_unneeded << 1) | (self.slow_motion << 2) | (self.hud_fps << 3),
+ )
+ except Exception:
+ _logger.exception('Error saving configuration')
+
+ @classmethod
+ def create_default(cls, path: Path, /) -> Self:
+ _logger.info('Creating default configuration file')
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ cfg = cls(path)
+ cfg.save()
+
+ return cfg
+
+ @classmethod
+ def load(cls, path: Path, /) -> Self:
+ if not path.exists():
+ _logger.info('No config file found, creating default configuration')
+ return cls.create_default(path)
+
+ try:
+ with path.open('rb') as f:
+ version = _read_char(f)
+ video_width = _read_short(f)
+ video_height = _read_short(f)
+
+ fullscreen = bool(_read_char(f))
+ video_scale = _read_char(f)
+ scale_method = _read_char(f)
+
+ keys = [_read_int(f) for _ in range(_CONTROLS - 4)]
+ buttons = [_read_int(f) for _ in range(_CONTROLS)]
+ axes = [(_read_int(f), _read_int(f)) for _ in range(_CONTROLS)]
+ hats = [(_read_int(f), _read_int(f)) for _ in range(_CONTROLS)]
+
+ name_bytes = [_read_char(f) for _ in range(_STRING_LENGTH)]
+ character_name = ''.join(chr(byte) for byte in name_bytes)
+ character_colors = [_read_char(f), _read_char(f), _read_char(f), _read_char(f)]
+
+ music_volume = _read_char(f)
+ sound_volume = _read_char(f)
+
+ opts = _read_char(f)
+ many_birds = bool(opts & 1)
+ leave_unneeded = bool(opts & 2)
+ slow_motion = bool(opts & 4)
+ hud_fps = bool(opts & 8)
+
+ return cls(
+ path,
+ version=version,
+ video_width=video_width,
+ video_height=video_height,
+ fullscreen=fullscreen,
+ video_scale=video_scale,
+ scale_method=scale_method,
+ keys=keys,
+ buttons=buttons,
+ axes=axes,
+ hats=hats,
+ character_name=character_name,
+ character_colors=character_colors,
+ music_volume=music_volume,
+ sound_volume=sound_volume,
+ many_birds=many_birds,
+ leave_unneeded=leave_unneeded,
+ slow_motion=slow_motion,
+ hud_fps=hud_fps,
+ )
+ except Exception:
+ _logger.exception('Error loading configuration')
+ _logger.info('Creating new default configuration')
+ return cls.create_default(path)
+
+
+def _log_config(cfg: _OpenJazzConfig, /) -> None:
+ _logger.info('OpenJazz Configuration:')
+ _logger.info('Version: %s', cfg.version)
+ _logger.info('Resolution: %sx%s', cfg.video_width, cfg.video_height)
+ _logger.info('Fullscreen: %s', cfg.fullscreen)
+ _logger.info('Video Scale: %s', cfg.video_scale)
+ _logger.info('Scale Method: %s', cfg.scale_method)
+ _logger.info('Character Name: %s', cfg.character_name)
+ _logger.info('Character Colors: %s', cfg.character_colors)
+ _logger.info('Music Volume: %s', cfg.music_volume)
+ _logger.info('Sound Volume: %s', cfg.sound_volume)
+ _logger.info('Many Birds: %s', cfg.many_birds)
+ _logger.info('Leave Unneeded: %s', cfg.leave_unneeded)
+ _logger.info('Slow Motion: %s', cfg.slow_motion)
+ _logger.info('HUD FPS: %s', cfg.hud_fps)
+ _logger.info('Controls Configuration:')
+ _logger.info('Keys: %s', cfg.keys)
+ _logger.info('Buttons: %s', cfg.buttons)
+ _logger.info('Axes: %s', cfg.axes)
+ _logger.info('Hats: %s', cfg.hats)
+
+
+@cached_dataclass
+class OpenJazz(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'OpenJazz',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return _ROM_DIR
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ cfg = _OpenJazzConfig.load(self.config_dir / 'openjazz.cfg')
+ _log_config(cfg)
+
+ if controller := Controller.find_player_number(self.controllers, 1):
+ for input in controller.inputs.values():
+ # Hats and axes are already configured correctly by default.
+ if input.type == 'button' and (slot := _BUTTON_SLOTS.get(input.name)) is not None:
+ cfg.buttons[slot] = int(input.id)
+ _logger.info('Configured Controls - Buttons: %s', cfg.buttons)
+
+ width_str, height_str = self.config.get_str(
+ 'jazz_resolution',
+ f'{self.resolution.width}x{self.resolution.height}',
+ ).split('x')
+ cfg.video_width = int(width_str)
+ cfg.video_height = int(height_str)
+
+ # Scaler: 0=None, 1=Bilinear, 2=Scale2x, 3=hqx
+ cfg.scale_method = int(self.config.get_str('jazz_scaler', '0'))
+
+ # Integer pixel-scale factor (clamped 1-4 by the engine)
+ cfg.video_scale = int(self.config.get_str('jazz_video_scale', '1'))
+
+ cfg.save()
+
+ return Command(
+ ['OpenJazz'],
+ env={
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_CACHE_HOME': CACHE,
+ 'XDG_DATA_HOME': SAVES,
+ },
+ )
diff --git a/python-src/batocera-launch-openjazz/pyproject.toml b/python-src/batocera-launch-openjazz/pyproject.toml
new file mode 100644
index 00000000000..fabb2e0d5b2
--- /dev/null
+++ b/python-src/batocera-launch-openjazz/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-openjazz"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+openjazz = 'batocera_launch_openjazz:OpenJazz'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_openjazz"]
diff --git a/python-src/batocera-launch-openjk/Config.in b/python-src/batocera-launch-openjk/Config.in
new file mode 100644
index 00000000000..2e0f38a98b9
--- /dev/null
+++ b/python-src/batocera-launch-openjk/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_OPENJK
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-openjk/batocera-launch-openjk.mk b/python-src/batocera-launch-openjk/batocera-launch-openjk.mk
new file mode 100644
index 00000000000..4a53ef57ec4
--- /dev/null
+++ b/python-src/batocera-launch-openjk/batocera-launch-openjk.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-openjk
+#
+################################################################################
+
+BATOCERA_LAUNCH_OPENJK_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_OPENJK_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-openjk/batocera_launch_openjk/__init__.py b/python-src/batocera-launch-openjk/batocera_launch_openjk/__init__.py
new file mode 100644
index 00000000000..3a8dfdf34f5
--- /dev/null
+++ b/python-src/batocera-launch-openjk/batocera_launch_openjk/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import OpenJK as OpenJK
diff --git a/python-src/batocera-launch-openjk/batocera_launch_openjk/emulator.py b/python-src/batocera-launch-openjk/batocera_launch_openjk/emulator.py
new file mode 100644
index 00000000000..206163d6324
--- /dev/null
+++ b/python-src/batocera-launch-openjk/batocera_launch_openjk/emulator.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+import logging
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS
+from batocera_launch import (
+ BatoceraException,
+ Command,
+ Emulator,
+ HotkeysContext,
+)
+
+_logger = logging.getLogger(__name__)
+
+_JEDI_ACADEMY: Final = Path('/usr/bin/JediAcademy')
+_JEDI_OUTCAST: Final = Path('/usr/bin/JediOutcast')
+
+
+def _update_config_file(file_path: Path, options_to_set: dict[str, str], remove_keys: list[str], /) -> None:
+ if file_path.is_file():
+ lines = file_path.read_text().splitlines(keepends=True)
+ lines = [line for line in lines if not any(key in line for key in remove_keys)]
+
+ for key, value in options_to_set.items():
+ option_line = f'{key} "{value}"\n'
+ if any(key in line for line in lines):
+ lines = [option_line if key in line else line for line in lines]
+ else:
+ lines.append(option_line)
+
+ file_path.write_text(''.join(lines))
+ else:
+ file_path.write_text(''.join(f'{key} "{value}"\n' for key, value in options_to_set.items()))
+
+ _logger.info('OpenJK config file %s updated.', file_path)
+
+
+def _copy_binaries(src_dir: Path, dest_dir: Path, binary_dest: Path, /) -> None:
+ binary_src = src_dir / binary_dest.name
+ if binary_dest.exists() and binary_src.stat().st_mtime <= binary_dest.stat().st_mtime:
+ return
+
+ for item in src_dir.iterdir():
+ dest_item = dest_dir / item.name
+ if item.is_dir():
+ shutil.copytree(item, dest_item, dirs_exist_ok=True)
+ else:
+ shutil.copy2(item, dest_item)
+ _logger.debug('Copying %s to %s', item, dest_item)
+
+ if binary_dest.is_file():
+ binary_dest.chmod(0o755)
+
+
+@cached_dataclass
+class OpenJK(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'openjk',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'restore_state': 'KEY_F9',
+ 'save_state': 'KEY_F12',
+ },
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.rom.parent
+
+ async def configure(self) -> Command:
+ rom_name = self.rom.name.lower()
+ if 'academy' in rom_name:
+ _logger.info('Found Jedi Academy!')
+ binary_src_path = _JEDI_ACADEMY
+ binary_dest = self.rom.parent / 'openjk_sp.x86_64'
+ config_file = CONFIGS / 'openjk' / 'base' / 'openjk_sp.cfg'
+ elif 'outcast' in rom_name:
+ _logger.info('Found Jedi Outcast!')
+ binary_src_path = _JEDI_OUTCAST
+ binary_dest = self.rom.parent / 'openjo_sp.x86_64'
+ config_file = CONFIGS / 'openjo' / 'base' / 'openjo_sp.cfg'
+ else:
+ _logger.info("Could not determine which game you're using!")
+ _logger.info('Rename your .jedi file as per the _infot.txt file')
+ raise BatoceraException('Could not determine game')
+
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ options_to_set = {
+ 'seta r_mode': '-2',
+ 'seta r_fullscreen': '1',
+ 'seta r_centerWindow': '1',
+ 'seta r_customheight': str(self.resolution.height),
+ 'seta r_customwidth': str(self.resolution.width),
+ }
+ remove_keys: list[str] = []
+
+ depth = self.config.get_str('openjk_colour')
+ if depth == '16':
+ options_to_set['seta r_colorbits'] = depth
+ options_to_set['seta r_depthbits'] = depth
+ elif depth == '32':
+ options_to_set['seta r_colorbits'] = depth
+ options_to_set['seta r_depthbits'] = '24'
+ else:
+ remove_keys.extend(['seta r_colorbits', 'seta r_depthbits'])
+
+ geometric = self.config.get_str('openjk_detail')
+ if geometric == 'Low':
+ options_to_set['seta r_lodbias'] = '2'
+ options_to_set['seta r_subdivisions'] = '20'
+ elif geometric == 'Medium':
+ options_to_set['seta r_lodbias'] = '1'
+ options_to_set['seta r_subdivisions'] = '12'
+ else:
+ remove_keys.extend(['seta r_lodbias', 'seta r_subdivisions'])
+
+ options_to_set['seta r_picmip'] = self.config.get_str('openjk_texture', '0')
+
+ if (texture_quality := self.config.get_str('openjk_texture_quality', '0')) != '0':
+ options_to_set['seta r_texturebits'] = texture_quality
+ else:
+ remove_keys.append('seta r_texturebits')
+
+ options_to_set['seta r_textureMode'] = self.config.get_str(
+ 'openjk_texture_filter',
+ 'GL_LINEAR_MIPMAP_LINEAR',
+ )
+
+ if not self.config.get_bool('openjk_shaders', True):
+ options_to_set['seta r_detailtextures'] = '0'
+ else:
+ remove_keys.append('seta r_detailtextures')
+
+ if self.config.get_bool('openjk_vsync'):
+ options_to_set['seta r_swapInterval'] = '1'
+ else:
+ remove_keys.append('seta r_swapInterval')
+
+ options_to_set['seta r_gamma'] = self.config.get_str('openjk_brightness', '1.000000')
+ options_to_set['seta cg_shadows'] = self.config.get_str('openjk_shadows', '1')
+ options_to_set['seta r_dynamiclight'] = self.config.get_str('openjk_lights', '1')
+
+ if self.config.get_bool('openjk_glow'):
+ options_to_set['seta r_DynamicGlow'] = '1'
+ else:
+ remove_keys.append('seta r_DynamicGlow')
+
+ options_to_set['seta r_flares'] = self.config.get_str('openjk_flares', '1')
+ options_to_set['seta cg_marks'] = self.config.get_str('openjk_wall', '1')
+ options_to_set['seta r_ext_texture_filter_anisotropic'] = self.config.get_str(
+ 'openjk_anistropic',
+ '16.000000',
+ )
+ options_to_set['seta cg_drawCrosshair'] = self.config.get_str('openjk_crosshair', '1')
+ options_to_set['seta cg_crosshairIdentifyTarget'] = self.config.get_str('openjk_target', '1')
+ options_to_set['seta d_slowmodeath'] = self.config.get_str('openjk_death', '3')
+ options_to_set['seta cg_gunAutoFirst'] = self.config.get_str('openjk_guns', '1')
+ options_to_set['seta g_dismemberment'] = self.config.get_str('openjk_dismember', '1')
+ options_to_set['seta ui_disableWeaponSway'] = self.config.get_str('openjk_sway', '0')
+ options_to_set['seta se_language'] = self.config.get_str('openjk_text', 'english')
+ options_to_set['seta s_language'] = self.config.get_str('openjk_voice', 'english')
+ options_to_set['seta g_subtitles'] = self.config.get_str('openjk_subtitles', '0')
+
+ _update_config_file(config_file, options_to_set, remove_keys)
+ _copy_binaries(binary_src_path, self.rom.parent, binary_dest)
+
+ return Command(
+ [binary_dest],
+ env={
+ 'XDG_DATA_HOME': CONFIGS,
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch-openjk/pyproject.toml b/python-src/batocera-launch-openjk/pyproject.toml
new file mode 100644
index 00000000000..040a4f916ff
--- /dev/null
+++ b/python-src/batocera-launch-openjk/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-openjk"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+openjk = 'batocera_launch_openjk:OpenJK'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_openjk"]
diff --git a/python-src/batocera-launch-openjkdf2/Config.in b/python-src/batocera-launch-openjkdf2/Config.in
new file mode 100644
index 00000000000..f9e55baeb04
--- /dev/null
+++ b/python-src/batocera-launch-openjkdf2/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_OPENJKDF2
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-openjkdf2/batocera-launch-openjkdf2.mk b/python-src/batocera-launch-openjkdf2/batocera-launch-openjkdf2.mk
new file mode 100644
index 00000000000..d1cb05f31bc
--- /dev/null
+++ b/python-src/batocera-launch-openjkdf2/batocera-launch-openjkdf2.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-openjkdf2
+#
+################################################################################
+
+BATOCERA_LAUNCH_OPENJKDF2_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_OPENJKDF2_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-openjkdf2/batocera_launch_openjkdf2/__init__.py b/python-src/batocera-launch-openjkdf2/batocera_launch_openjkdf2/__init__.py
new file mode 100644
index 00000000000..1e72923288d
--- /dev/null
+++ b/python-src/batocera-launch-openjkdf2/batocera_launch_openjkdf2/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import OpenJKDF2 as OpenJKDF2
diff --git a/python-src/batocera-launch-openjkdf2/batocera_launch_openjkdf2/emulator.py b/python-src/batocera-launch-openjkdf2/batocera_launch_openjkdf2/emulator.py
new file mode 100644
index 00000000000..40c9a5d8dbf
--- /dev/null
+++ b/python-src/batocera-launch-openjkdf2/batocera_launch_openjkdf2/emulator.py
@@ -0,0 +1,474 @@
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from batocera_launch import SystemConfig
+
+_logger = logging.getLogger(__name__)
+
+_BINARY_SRC: Final = Path('/usr/bin/openjkdf2')
+
+# Represents the file content when created for the first time
+_DEFAULT_PLAYER_CONFIG_TEMPLATE: Final = [
+ 'version 1\n',
+ 'diff 1\n',
+ 'fullsubtitles 0\n',
+ 'disablecutscenes 0\n',
+ 'rotateoverlaymap 1\n',
+ 'drawstatus 1\n',
+ 'crosshair 0\n',
+ 'sabercam 0\n',
+ 'autoPickup 1\n',
+ 'autoSwitch 3\n',
+ 'autoReload 0\n',
+ 'multiAutoPickup 15\n',
+ 'multiAutoSwitch 3\n',
+ 'multiAutoReload 3\n',
+ 'autoAim 1\n',
+ 'flags=24\n',
+ 'bind 0 200 0x2\n',
+ 'bind 0 208 0x6\n',
+ 'bind 0 17 0x2\n',
+ 'bind 0 31 0x6\n',
+ 'bind 0 72 0x2\n',
+ 'bind 0 80 0x6\n',
+ 'bind 0 1 0x5\n',
+ 'bind 1 75 0x6\n',
+ 'bind 1 203 0x2\n',
+ 'bind 1 205 0x6\n',
+ 'bind 1 0 0x5\n',
+ 'bind 1 12 0xd 0.400000\n',
+ 'bind 2 30 0x6\n',
+ 'bind 2 32 0x2\n',
+ 'bind 2 79 0x6\n',
+ 'bind 2 81 0x2\n',
+ 'bind 2 264 0x6\n',
+ 'bind 2 266 0x2\n',
+ 'bind 3 184 0x2\n',
+ 'bind 3 56 0x2\n',
+ 'bind 4 78 0x2\n',
+ 'bind 4 45 0x2\n',
+ 'bind 4 259 0x2\n',
+ 'bind 4 281 0x2\n',
+ 'bind 5 46 0x2\n',
+ 'bind 6 42 0x2\n',
+ 'bind 6 54 0x2\n',
+ 'bind 7 58 0x2\n',
+ 'bind 8 201 0x6\n',
+ 'bind 8 209 0x2\n',
+ 'bind 8 265 0x6\n',
+ 'bind 8 267 0x2\n',
+ 'bind 8 13 0xd 0.300000\n',
+ 'bind 8 14 0x1 4.000000\n',
+ 'bind 9 199 0x2\n',
+ 'bind 9 76 0x2\n',
+ 'bind 10 157 0x2\n',
+ 'bind 10 29 0x2\n',
+ 'bind 10 256 0x2\n',
+ 'bind 10 280 0x2\n',
+ 'bind 11 44 0x2\n',
+ 'bind 11 82 0x2\n',
+ 'bind 11 257 0x2\n',
+ 'bind 11 282 0x2\n',
+ 'bind 12 57 0x2\n',
+ 'bind 12 258 0x2\n',
+ 'bind 13 2 0x2\n',
+ 'bind 14 3 0x2\n',
+ 'bind 15 4 0x2\n',
+ 'bind 16 5 0x2\n',
+ 'bind 17 6 0x2\n',
+ 'bind 18 7 0x2\n',
+ 'bind 19 8 0x2\n',
+ 'bind 20 9 0x2\n',
+ 'bind 21 10 0x2\n',
+ 'bind 22 11 0x2\n',
+ 'bind 23 67 0x2\n',
+ 'bind 25 19 0x2\n',
+ 'bind 25 27 0x2\n',
+ 'bind 25 260 0x2\n',
+ 'bind 26 26 0x2\n',
+ 'bind 27 28 0x2\n',
+ 'bind 27 262 0x2\n',
+ 'bind 28 53 0x2\n',
+ 'bind 28 34 0x2\n',
+ 'bind 29 52 0x2\n',
+ 'bind 30 40 0x2\n',
+ 'bind 30 18 0x2\n',
+ 'bind 31 39 0x2\n',
+ 'bind 31 16 0x2\n',
+ 'bind 32 33 0x2\n',
+ 'bind 33 15 0x2\n',
+ 'bind 34 13 0x2\n',
+ 'bind 35 12 0x2\n',
+ 'bind 36 47 0x2\n',
+ 'bind 37 59 0x2\n',
+ 'bind 38 20 0x2\n',
+ 'bind 39 87 0x2\n',
+ 'bind 40 88 0x2\n',
+ 'bind 41 41 0x2\n',
+ 'bind 42 63 0x2\n',
+ 'bind 43 64 0x2\n',
+ 'bind 44 65 0x2\n',
+ 'bind 45 66 0x2\n',
+ 'bind 56 62 0x2\n',
+ 'bind 57 61 0x2\n',
+ 'bind 58 60 0x2\n',
+ 'end.\n',
+ 'numCutscenes 1\n',
+ '01-02a.smk 1\n',
+]
+
+_DEFAULT_TEMPLATE_LINE_MAP: Final[dict[str, int]] = {
+ parts[0]: i
+ for i, line in enumerate(_DEFAULT_PLAYER_CONFIG_TEMPLATE)
+ if len(parts := line.strip().split(' ', 1)) > 1
+}
+
+_PLAYER_SETTINGS_MAP: Final = {
+ # Batocera Key: (PLR Key, Template Default Value)
+ 'jkdf2_difficulty': ('diff', '1'),
+ 'jkdf2_subs': ('fullsubtitles', '0'),
+ 'jkdf2_scenes': ('disablecutscenes', '0'),
+ 'jkdf2_map_rotate': ('rotateoverlaymap', '1'),
+ 'jkdf2_drawstatus': ('drawstatus', '1'),
+ 'jkdf2_crosshair': ('crosshair', '0'),
+ 'jkdf2_saber_camera': ('sabercam', '0'),
+ 'jkdf2_aiming': ('autoAim', '1'),
+}
+
+_JSON_SETTINGS_MAP: Final = {
+ 'jkdf2_waggle': ('bDisableWeaponWaggle', False),
+ 'jkdf2_jkgm': ('bEnableJkgm', True),
+ 'jkdf2_cache': ('bEnableTexturePrecache', True),
+ 'jkdf2_start': ('bFastMissionText', False),
+ 'jkdf2_janky': ('bJankyPhysics', False),
+ 'jkdf2_corpses': ('bKeepCorpses', False),
+ 'jkdf2_physics': ('bUseOldPlayerPhysics', False),
+ 'jkdf2_cogtickrate': ('canonicalCogTickrate', 0.019999999552965164),
+ 'jkdf2_phystickrate': ('canonicalPhysTickrate', 0.03999999910593033),
+ 'jkdf2_cross_line': ('crosshairLineWidth', 1.0),
+ 'jkdf2_cross_size': ('crosshairScale', 1.0),
+ 'jkdf2_bloom': ('enablebloom', False),
+ 'jkdf2_ssao': ('enablessao', 0),
+ 'jkdf2_vsync': ('enablevsync', False),
+ 'jkdf2_fov': ('fov', 90),
+ 'jkdf2_fov_vert': ('fovisvertical', True),
+ 'jkdf2_fps': ('fpslimit', 0),
+ 'jkdf2_gamma': ('gamma', 1.0),
+ 'jkdf2_hud_scale': ('hudScale', 2.0),
+ 'jkdf2_aspect': ('originalaspect', False),
+ 'jkdf2_fist_cross': ('setCrosshairOnFist', True),
+ 'jkdf2_saber_cross': ('setCrosshairOnLightsaber', True),
+ 'jkdf2_ssaa_multiple': ('ssaamultiple', 1.0),
+ 'jkdf2_texture': ('texturefiltering', False),
+ 'jkdf2_fullscreen': ('windowfullscreen', True),
+ 'jkdf2_hidpi': ('windowishidpi', True),
+}
+
+_CVAR_SETTINGS_MAP: Final = {
+ 'jkdf2_janky': ('g_bJankyPhysics', False),
+ 'jkdf2_corpses': ('g_bKeepCorpses', False),
+ 'jkdf2_physics': ('g_bUseOldPlayerPhysics', False),
+ 'jkdf2_cogtickrate': ('g_canonicalCogTickrate', 0.019999999552965164),
+ 'jkdf2_phystickrate': ('g_canonicalPhysTickrate', 0.03999999910593033),
+ 'jkdf2_cross_line': ('hud_crosshairLineWidth', 1.0),
+ 'jkdf2_cross_size': ('hud_crosshairScale', 1.0),
+ 'jkdf2_waggle': ('hud_disableWeaponWaggle', False),
+ 'jkdf2_hud_scale': ('hud_scale', 2.0),
+ 'jkdf2_fist_cross': ('hud_setCrosshairOnFist', True),
+ 'jkdf2_saber_cross': ('hud_setCrosshairOnLightsaber', True),
+ 'jkdf2_start': ('menu_bFastMissionText', False),
+ 'jkdf2_jkgm': ('r_bEnableJkgm', True),
+ 'jkdf2_cache': ('r_bEnableTexturePrecache', True),
+ 'jkdf2_bloom': ('r_enableBloom', False),
+ 'jkdf2_aspect': ('r_enableOrigAspect', False),
+ 'jkdf2_ssao': ('r_enableSSAO', False),
+ 'jkdf2_texture': ('r_enableTextureFilter', False),
+ 'jkdf2_vsync': ('r_enableVsync', False),
+ 'jkdf2_fov': ('r_fov', 90),
+ 'jkdf2_fov_vert': ('r_fovIsVertical', True),
+ 'jkdf2_fps': ('r_fpslimit', 0),
+ 'jkdf2_fullscreen': ('r_fullscreen', True),
+ 'jkdf2_gamma': ('r_gamma', 1.0),
+ 'jkdf2_hidpi': ('r_hidpi', True),
+ 'jkdf2_ssaa_multiple': ('r_ssaaMultiple', 1.0),
+}
+
+
+def _apply_settings_to_template(settings_to_set: dict[str, str], /) -> list[str]:
+ output_lines = _DEFAULT_PLAYER_CONFIG_TEMPLATE[:]
+ for plr_key, new_line_content in settings_to_set.items():
+ if (line_index := _DEFAULT_TEMPLATE_LINE_MAP.get(plr_key)) is not None:
+ output_lines[line_index] = new_line_content
+ return output_lines
+
+
+def _update_player_config(config_file: Path, settings_to_set: dict[str, str], /) -> None:
+ output_lines: list[str] = []
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ if not config_file.exists():
+ _logger.debug('Config file %s not found. Creating from template.', config_file)
+ output_lines = _apply_settings_to_template(settings_to_set)
+ else:
+ _logger.debug('Config file %s exists. Modifying.', config_file)
+ settings_processed = settings_to_set.copy()
+ original_lines: list[str] = []
+ try:
+ original_lines = config_file.read_text().splitlines(keepends=True)
+ except OSError as e:
+ _logger.error('Error reading existing player config file %s: %s. Cannot modify.', config_file, e)
+ _logger.info('Falling back to creating %s from template due to read error.', config_file)
+ output_lines = _apply_settings_to_template(settings_to_set)
+ original_lines = []
+
+ if original_lines and (not output_lines) and (not original_lines[0].strip().lower().startswith('version ')):
+ _logger.warning("Existing config %s missing 'version 1' at start. Prepending.", config_file)
+ output_lines.append('version 1\n')
+ start_index = 1 if original_lines[0].strip().lower().startswith('version ') else 0
+ original_lines = original_lines[start_index:]
+
+ if original_lines and not output_lines:
+ for line in original_lines:
+ stripped_line = line.strip()
+ if stripped_line.lower().startswith('version '):
+ if not output_lines:
+ output_lines.append(line)
+ continue
+ found_match = False
+ for setting_key in list(settings_processed):
+ if stripped_line.startswith(f'{setting_key} '):
+ output_lines.append(settings_processed[setting_key])
+ del settings_processed[setting_key]
+ found_match = True
+ break
+ if not found_match:
+ output_lines.append(line)
+ if settings_processed:
+ _logger.warning(
+ 'Settings %s were not found in existing file %s. Appending them.',
+ list(settings_processed.keys()),
+ config_file,
+ )
+ output_lines.extend(settings_processed.values())
+
+ try:
+ config_file.write_text(''.join(output_lines))
+ _logger.debug('Successfully wrote player config file: %s', config_file)
+ except OSError as e:
+ _logger.error('Error writing player config file %s: %s', config_file, e)
+
+
+def _update_json_config(config_file: Path, settings_to_set: dict[str, Any], /) -> None:
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+ existing_data: dict[str, Any] = {}
+ if config_file.exists():
+ try:
+ content = config_file.read_text()
+ if content.strip():
+ existing_data = json.loads(content)
+ else:
+ _logger.warning('JSON config file %s is empty.', config_file)
+ except (OSError, json.JSONDecodeError) as e:
+ _logger.error('Error reading/parsing JSON config file %s: %s. Starting fresh.', config_file, e)
+ existing_data = {}
+
+ existing_data.update(settings_to_set)
+
+ try:
+ config_file.write_text(json.dumps(existing_data, indent=4))
+ except OSError as e:
+ _logger.error('Error writing JSON config file %s: %s', config_file, e)
+
+
+def _convert_to_json_value(
+ config: SystemConfig,
+ config_key: str,
+ default_value: bool | float,
+ /,
+) -> bool | int | float:
+ config_value = config.get(config_key)
+ if config_value is None:
+ return default_value
+
+ try:
+ if isinstance(default_value, bool):
+ return config.get_bool(config_key)
+ if isinstance(default_value, int):
+ if isinstance(config_value, bool):
+ return 1 if config_value else 0
+ return int(float(config_value))
+ if isinstance(config_value, bool):
+ return 1.0 if config_value else 0.0
+ return float(config_value)
+ except (ValueError, TypeError) as e:
+ _logger.warning(
+ "Conversion failed for '%s' to %s. Using default '%s'. Error: %s",
+ config_value,
+ type(default_value).__name__,
+ default_value,
+ e,
+ )
+ return default_value
+
+
+@cached_dataclass
+class OpenJKDF2(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'openjkdf2',
+ 'keys': {
+ 'exit': 'KEY_F10',
+ 'save_state': 'KEY_F9',
+ 'screenshot': 'KEY_F12',
+ },
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return self.config.get_bool('jkdf2_aspect', return_values=(4 / 3, 16 / 9))
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.rom.parent
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return self.rom.parent / 'player' / 'Batocera'
+
+ async def configure(self) -> Command:
+ rom_dir = self.rom.parent
+ binary_dest = rom_dir / 'openjkdf2'
+ config_file = self.config_dir / 'openjkdf2.json'
+ cvar_file = self.config_dir / 'openjkdf2_cvars.json'
+ player_file = self.config_dir / 'Batocera.plr'
+
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ try:
+ _logger.debug('Preparing settings for player config: %s', player_file)
+ target_settings = {
+ plr_key: f'{plr_key} {self.config.get_str(config_key, template_default)}\n'
+ for config_key, (plr_key, template_default) in _PLAYER_SETTINGS_MAP.items()
+ }
+
+ auto_pickup = (
+ self.config.get_int('jkdf2_pickup', 1) * 1
+ + self.config.get_int('jkdf2_dangerous', 0) * 2
+ + self.config.get_int('jkdf2_weaker', 0) * 4
+ + self.config.get_int('jkdf2_saber', 0) * 8
+ )
+ auto_switch = (
+ self.config.get_int('jkdf2_switch', 1) * 1
+ + self.config.get_int(
+ 'jkdf2_switch_dangerous',
+ 1,
+ )
+ * 2
+ )
+ auto_reload = (
+ self.config.get_int('jkdf2_reload', 0) * 1
+ + self.config.get_int(
+ 'jkdf2_reload_saber',
+ 0,
+ )
+ * 2
+ )
+ multi_pickup = (
+ self.config.get_int('jkdf2_pickup', 1) * 1
+ + self.config.get_int('jkdf2_dangerous', 1) * 2
+ + self.config.get_int('jkdf2_weaker', 1) * 4
+ + self.config.get_int('jkdf2_saber', 1) * 8
+ )
+ multi_switch = (
+ self.config.get_int('jkdf2_switch', 1) * 1
+ + self.config.get_int(
+ 'jkdf2_switch_dangerous',
+ 1,
+ )
+ * 2
+ )
+ multi_reload = (
+ self.config.get_int('jkdf2_reload', 1) * 1
+ + self.config.get_int(
+ 'jkdf2_reload_saber',
+ 1,
+ )
+ * 2
+ )
+
+ target_settings['autoPickup'] = f'autoPickup {auto_pickup}\n'
+ target_settings['autoSwitch'] = f'autoSwitch {auto_switch}\n'
+ target_settings['autoReload'] = f'autoReload {auto_reload}\n'
+ target_settings['multiAutoPickup'] = f'multiAutoPickup {multi_pickup}\n'
+ target_settings['multiAutoSwitch'] = f'multiAutoSwitch {multi_switch}\n'
+ target_settings['multiAutoReload'] = f'multiAutoReload {multi_reload}\n'
+
+ _update_player_config(player_file, target_settings)
+ except Exception:
+ _logger.exception('Error preparing player configuration for %s', player_file)
+
+ try:
+ _logger.debug('Generating JSON config: %s', config_file)
+ _update_json_config(
+ config_file,
+ {
+ json_key: (
+ self.config.get_bool(config_key, return_values=(1, 0))
+ if config_key == 'jkdf2_ssao'
+ else _convert_to_json_value(self.config, config_key, default)
+ )
+ for config_key, (json_key, default) in _JSON_SETTINGS_MAP.items()
+ },
+ )
+ except Exception:
+ _logger.exception('Error preparing JSON configuration for %s', config_file)
+
+ try:
+ _logger.debug('Generating CVAR JSON config: %s', cvar_file)
+ _update_json_config(
+ cvar_file,
+ {
+ json_key: _convert_to_json_value(self.config, config_key, default)
+ for config_key, (json_key, default) in _CVAR_SETTINGS_MAP.items()
+ },
+ )
+ except Exception:
+ _logger.exception('Error preparing CVAR JSON configuration for %s', cvar_file)
+
+ registry_file = rom_dir / 'registry.json'
+ if registry_file.exists():
+ _logger.debug('Registry file found. Updating config: %s', registry_file)
+ _update_json_config(
+ registry_file,
+ {
+ 'playerShortName': 'Batocera',
+ 'Window_isFullscreen': True,
+ },
+ )
+ else:
+ _logger.debug('Registry file %s does not exist yet. Skipping config update.', registry_file)
+
+ if not binary_dest.exists() or _BINARY_SRC.stat().st_mtime > binary_dest.stat().st_mtime:
+ shutil.copy2(_BINARY_SRC, binary_dest)
+ binary_dest.chmod(0o755)
+
+ return Command(
+ [binary_dest],
+ env={'SDL_JOYSTICK_HIDAPI': '0'},
+ )
diff --git a/python-src/batocera-launch-openjkdf2/pyproject.toml b/python-src/batocera-launch-openjkdf2/pyproject.toml
new file mode 100644
index 00000000000..257a59344ed
--- /dev/null
+++ b/python-src/batocera-launch-openjkdf2/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-openjkdf2"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+openjkdf2 = 'batocera_launch_openjkdf2:OpenJKDF2'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_openjkdf2"]
diff --git a/python-src/batocera-launch-openmohaa/Config.in b/python-src/batocera-launch-openmohaa/Config.in
new file mode 100644
index 00000000000..8beffaeac61
--- /dev/null
+++ b/python-src/batocera-launch-openmohaa/Config.in
@@ -0,0 +1,4 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_OPENMOHAA
+ bool
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-openmohaa/batocera-launch-openmohaa.mk b/python-src/batocera-launch-openmohaa/batocera-launch-openmohaa.mk
new file mode 100644
index 00000000000..d08b5f8030d
--- /dev/null
+++ b/python-src/batocera-launch-openmohaa/batocera-launch-openmohaa.mk
@@ -0,0 +1,12 @@
+################################################################################
+#
+# batocera-launch-openmohaa
+#
+################################################################################
+
+BATOCERA_LAUNCH_OPENMOHAA_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_OPENMOHAA_DEPENDENCIES = \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-openmohaa/batocera_launch_openmohaa/__init__.py b/python-src/batocera-launch-openmohaa/batocera_launch_openmohaa/__init__.py
new file mode 100644
index 00000000000..c62fd68e688
--- /dev/null
+++ b/python-src/batocera-launch-openmohaa/batocera_launch_openmohaa/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import OpenMOHAA as OpenMOHAA
diff --git a/python-src/batocera-launch-openmohaa/batocera_launch_openmohaa/emulator.py b/python-src/batocera-launch-openmohaa/batocera_launch_openmohaa/emulator.py
new file mode 100644
index 00000000000..164cf0165c4
--- /dev/null
+++ b/python-src/batocera-launch-openmohaa/batocera_launch_openmohaa/emulator.py
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+_TERRAIN_DETAIL: Final[dict[str, tuple[str, str]]] = {
+ '0': ('3', '10'),
+ '1': ('4', '9'),
+ '2': ('5', '7'),
+}
+
+_MODEL_DETAIL: Final[dict[str, tuple[str, str, str]]] = {
+ '0': ('0.25', '0.25', '0.25'),
+ '1': ('0.25', '0.35', '0.35'),
+ '2': ('0.45', '0.35', '0.45'),
+ '3': ('0.55', '0.5', '0.55'),
+ '4': ('0.9', '0.9', '0.9'),
+}
+
+_EFFECTS_DETAIL: Final[dict[str, tuple[str, str]]] = {
+ '1': ('0.3', '23'),
+ '2': ('0.5', '22'),
+ '3': ('0.7', '20'),
+ '4': ('0.8', '18'),
+ '5': ('0.95', '15'),
+ '6': ('1.0', '10'),
+}
+
+_CURVE_DETAIL: Final[dict[str, str]] = {
+ '0': '20',
+ '1': '10',
+ '3': '3',
+}
+
+
+def _update_config_file(file_path: Path, options_to_set: dict[str, str], /) -> None:
+ if file_path.is_file():
+ lines = file_path.read_text().splitlines(keepends=True)
+ options_in_file: set[str] = set()
+ for i, line in enumerate(lines):
+ stripped_line = line.strip()
+ for key, value in options_to_set.items():
+ if stripped_line.startswith(f'{key} '):
+ lines[i] = f'{key} "{value}"\n'
+ options_in_file.add(key)
+ break
+ lines.extend(f'{key} "{value}"\n' for key, value in options_to_set.items() if key not in options_in_file)
+ file_path.write_text(''.join(lines))
+ else:
+ file_path.write_text(''.join(f'{key} "{value}"\n' for key, value in options_to_set.items()))
+
+
+@cached_dataclass
+class OpenMOHAA(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'openmohaa',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': 'KEY_F5',
+ 'restore_state': 'KEY_F9',
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_PAUSE',
+ },
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.rom.parent
+
+ async def configure(self) -> Command:
+ rom_name = self.rom.name.lower()
+ variant_subdir = 'main'
+ target_game = '0'
+ if 'spear' in rom_name:
+ _logger.info('Found Spearhead!')
+ variant_subdir = 'mainta'
+ target_game = '1'
+ elif 'break' in rom_name:
+ _logger.info('Found Breakthrough!')
+ variant_subdir = 'maintt'
+ target_game = '2'
+
+ config_file = self.config_dir / variant_subdir / 'configs' / 'omconfig.cfg'
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ ter_maxlod, ter_error = _TERRAIN_DETAIL.get(self.config.get_str('mohaa_terrain', ''), ('6', '4'))
+ r_lodviewmodelcap, r_lodcap, r_lodscale = _MODEL_DETAIL.get(
+ self.config.get_str('mohaa_model', ''),
+ ('0.25', '0.35', '5'),
+ )
+ cg_effectdetail, vss_maxcount = _EFFECTS_DETAIL.get(
+ self.config.get_str('mohaa_effects', ''),
+ ('0.2', '22'),
+ )
+
+ options_to_set = {
+ 'seta r_mode': '-1',
+ 'seta r_fullscreen': '1',
+ 'seta r_allowResize': '0',
+ 'seta r_centerWindow': '1',
+ 'seta r_customheight': f'"{self.resolution.height}"',
+ 'seta r_customwidth': f'"{self.resolution.width}"',
+ 'seta r_customaspect': '1',
+ 'bind [': 'weapprev',
+ 'bind ]': 'weapnext',
+ # -= Video Options =-
+ 'seta r_colorbits': self.config.get_str('mohaa_colour', '0'),
+ 'seta r_picmip': self.config.get_str('mohaa_texture', '1'),
+ 'seta r_texturebits': self.config.get_str('mohaa_texture_colour', '0'),
+ 'seta r_textureMode': self.config.get_str('mohaa_texture_filter', 'GL_LINEAR_MIPMAP_NEAREST'),
+ 'seta cg_marks_add': self.config.get_str('mohaa_decals', '0'),
+ 'seta cg_rain': self.config.get_str('mohaa_weather', '0'),
+ 'seta r_gamma': self.config.get_str('mohaa_brightness', '1.000000'),
+ 'seta r_ext_compressed_textures': self.config.get_str('mohaa_compression', '0'),
+ # -= Advanced Options =-
+ 'seta cg_drawviewmodel': self.config.get_str('mohaa_view', '2'),
+ 'seta cg_shadows': self.config.get_str('mohaa_shadows', '1'),
+ 'seta ter_maxlod': ter_maxlod,
+ 'seta ter_error': ter_error,
+ 'seta r_lodviewmodelcap': r_lodviewmodelcap,
+ 'seta r_lodcap': r_lodcap,
+ 'seta r_lodscale': r_lodscale,
+ 'seta cg_effectdetail': cg_effectdetail,
+ 'seta vss_maxcount': vss_maxcount,
+ 'seta r_subdivisions': _CURVE_DETAIL.get(self.config.get_str('mohaa_curve', ''), '4'),
+ 'seta g_subtitle': self.config.get_str('mohaa_subtitles', '0'),
+ 'seta r_fastdlights': self.config.get_str('mohaa_dynamic_lighting', '1'),
+ 'seta r_fastentlight': self.config.get_str('mohaa_entity_lighting', '1'),
+ 'seta vss_draw': self.config.get_str('mohaa_smoke', '0'),
+ 'seta ui_weaponsbar': self.config.get_str('mohaa_weapons', '1'),
+ 'seta ui_crosshair': self.config.get_str('mohaa_crosshair', '1'),
+ }
+
+ _update_config_file(config_file, options_to_set)
+
+ return Command(
+ [
+ '/usr/bin/openmohaa/openmohaa',
+ '+set',
+ 'com_homepath',
+ # Not the full config_path
+ 'configs/openmohaa',
+ # Set the target game via command line argument
+ '+set',
+ 'com_target_game',
+ target_game,
+ ]
+ )
diff --git a/python-src/batocera-launch-openmohaa/pyproject.toml b/python-src/batocera-launch-openmohaa/pyproject.toml
new file mode 100644
index 00000000000..a0e0c3753b9
--- /dev/null
+++ b/python-src/batocera-launch-openmohaa/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "batocera-launch-openmohaa"
+version = '44.0'
+dependencies = [
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+openmohaa = 'batocera_launch_openmohaa:OpenMOHAA'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_openmohaa"]
diff --git a/python-src/batocera-launch-rpcs3/Config.in b/python-src/batocera-launch-rpcs3/Config.in
new file mode 100644
index 00000000000..3bf172ad1a2
--- /dev/null
+++ b/python-src/batocera-launch-rpcs3/Config.in
@@ -0,0 +1,5 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH_RPCS3
+ bool
+ select BR2_PACKAGE_PYTHON_AIOHTTP
+ depends on BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ depends on BR2_PACKAGE_BATOCERA_LAUNCH
diff --git a/python-src/batocera-launch-rpcs3/batocera-launch-rpcs3.mk b/python-src/batocera-launch-rpcs3/batocera-launch-rpcs3.mk
new file mode 100644
index 00000000000..035ff97a03d
--- /dev/null
+++ b/python-src/batocera-launch-rpcs3/batocera-launch-rpcs3.mk
@@ -0,0 +1,13 @@
+################################################################################
+#
+# batocera-launch-rpcs3
+#
+################################################################################
+
+BATOCERA_LAUNCH_RPCS3_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_RPCS3_DEPENDENCIES = \
+ python-aiohttp \
+ python-batocera-common \
+ batocera-launch
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/__init__.py b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/__init__.py
new file mode 100644
index 00000000000..3cac39bcab5
--- /dev/null
+++ b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from batocera_launch_rpcs3.emulator import RPCS3 as RPCS3
diff --git a/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/controllers.py b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/controllers.py
new file mode 100644
index 00000000000..eccb8af47f4
--- /dev/null
+++ b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/controllers.py
@@ -0,0 +1,641 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any, Final
+
+from batocera_common.yaml import safe_dump_yaml12
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from batocera_launch import Controllers, SystemConfig
+
+_logger = logging.getLogger(__name__)
+
+# Fixed keyboard input config for the arcade titles: players 1 and 2 on a single
+# keyboard, the remaining slots disabled. Written verbatim as the global Default.yml.
+_ARCADE_KEYBOARD_CONFIG: Final = """\
+Player 1 Input:
+ Handler: Keyboard
+ Device: Keyboard
+ Config:
+ Left Stick Left: ""
+ Left Stick Down: ""
+ Left Stick Right: ""
+ Left Stick Up: ""
+ Right Stick Left: ""
+ Right Stick Down: ""
+ Right Stick Right: ""
+ Right Stick Up: ""
+ Start: 1
+ Select: F2
+ PS Button: ""
+ Square: E
+ Cross: T
+ Circle: Y
+ Triangle: R
+ Left: A
+ Down: S
+ Right: D
+ Up: W
+ R1: U
+ R2: ""
+ R3: F1
+ L1: I
+ L2: ""
+ L3: 5
+ IR Nose: ""
+ IR Tail: ""
+ IR Left: ""
+ IR Right: ""
+ Tilt Left: ""
+ Tilt Right: ""
+ Motion Sensor X:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor Y:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor Z:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor G:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Orientation Reset Button: ""
+ Orientation Enabled: false
+ Pressure Intensity Button: ""
+ Pressure Intensity Percent: 50
+ Pressure Intensity Toggle Mode: false
+ Pressure Intensity Deadzone: 0
+ Analog Limiter Button: ""
+ Analog Limiter Toggle Mode: false
+ Left Stick Multiplier: 100
+ Right Stick Multiplier: 100
+ Left Stick Deadzone: 0
+ Right Stick Deadzone: 0
+ Left Stick Anti-Deadzone: 0
+ Right Stick Anti-Deadzone: 0
+ Left Trigger Threshold: 0
+ Right Trigger Threshold: 0
+ Left Pad Squircling Factor: 8000
+ Right Pad Squircling Factor: 8000
+ Color Value R: 0
+ Color Value G: 0
+ Color Value B: 0
+ Blink LED when battery is below 20%: true
+ Use LED as a battery indicator: false
+ LED battery indicator brightness: 50
+ Player LED enabled: true
+ Large Vibration Motor Multiplier: 100
+ Small Vibration Motor Multiplier: 100
+ Switch Vibration Motors: false
+ Vibration Threshold: 63
+ Mouse Movement Mode: Relative
+ Mouse Deadzone X Axis: 60
+ Mouse Deadzone Y Axis: 60
+ Mouse Acceleration X Axis: 200
+ Mouse Acceleration Y Axis: 250
+ Left Stick Lerp Factor: 100
+ Right Stick Lerp Factor: 100
+ Analog Button Lerp Factor: 100
+ Trigger Lerp Factor: 100
+ Device Class Type: 0
+ Vendor ID: 1356
+ Product ID: 616
+ Buddy Device: ""
+Player 2 Input:
+ Handler: Keyboard
+ Device: Keyboard
+ Config:
+ Left Stick Left: ""
+ Left Stick Down: ""
+ Left Stick Right: ""
+ Left Stick Up: ""
+ Right Stick Left: ""
+ Right Stick Down: ""
+ Right Stick Right: ""
+ Right Stick Up: ""
+ Start: 2
+ Select: F2
+ PS Button: ""
+ Square: F
+ Cross: H
+ Circle: J
+ Triangle: G
+ Left: Left
+ Down: Down
+ Right: Right
+ Up: Up
+ R1: K
+ R2: ""
+ R3: F1
+ L1: L
+ L2: ""
+ L3: 6
+ IR Nose: ""
+ IR Tail: ""
+ IR Left: ""
+ IR Right: ""
+ Tilt Left: ""
+ Tilt Right: ""
+ Motion Sensor X:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor Y:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor Z:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor G:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Orientation Reset Button: ""
+ Orientation Enabled: false
+ Pressure Intensity Button: ""
+ Pressure Intensity Percent: 50
+ Pressure Intensity Toggle Mode: false
+ Pressure Intensity Deadzone: 0
+ Analog Limiter Button: ""
+ Analog Limiter Toggle Mode: false
+ Left Stick Multiplier: 100
+ Right Stick Multiplier: 100
+ Left Stick Deadzone: 0
+ Right Stick Deadzone: 0
+ Left Stick Anti-Deadzone: 0
+ Right Stick Anti-Deadzone: 0
+ Left Trigger Threshold: 0
+ Right Trigger Threshold: 0
+ Left Pad Squircling Factor: 8000
+ Right Pad Squircling Factor: 8000
+ Color Value R: 0
+ Color Value G: 0
+ Color Value B: 0
+ Blink LED when battery is below 20%: true
+ Use LED as a battery indicator: false
+ LED battery indicator brightness: 50
+ Player LED enabled: true
+ Large Vibration Motor Multiplier: 100
+ Small Vibration Motor Multiplier: 100
+ Switch Vibration Motors: false
+ Vibration Threshold: 63
+ Mouse Movement Mode: Relative
+ Mouse Deadzone X Axis: 60
+ Mouse Deadzone Y Axis: 60
+ Mouse Acceleration X Axis: 200
+ Mouse Acceleration Y Axis: 250
+ Left Stick Lerp Factor: 100
+ Right Stick Lerp Factor: 100
+ Analog Button Lerp Factor: 100
+ Trigger Lerp Factor: 100
+ Device Class Type: 0
+ Vendor ID: 1356
+ Product ID: 616
+ Buddy Device: "Null"
+""" + ''.join(
+ f"""Player {nplayer} Input:
+ Handler: "Null"
+ Device: "Null"
+ Config:
+ Left Stick Left: ""
+ Left Stick Down: ""
+ Left Stick Right: ""
+ Left Stick Up: ""
+ Right Stick Left: ""
+ Right Stick Down: ""
+ Right Stick Right: ""
+ Right Stick Up: ""
+ Start: ""
+ Select: ""
+ PS Button: ""
+ Square: ""
+ Cross: ""
+ Circle: ""
+ Triangle: ""
+ Left: ""
+ Down: ""
+ Right: ""
+ Up: ""
+ R1: ""
+ R2: ""
+ R3: ""
+ L1: ""
+ L2: ""
+ L3: ""
+ IR Nose: ""
+ IR Tail: ""
+ IR Left: ""
+ IR Right: ""
+ Tilt Left: ""
+ Tilt Right: ""
+ Motion Sensor X:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor Y:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor Z:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Motion Sensor G:
+ Axis: ""
+ Mirrored: false
+ Shift: 0
+ Orientation Reset Button: ""
+ Orientation Enabled: false
+ Pressure Intensity Button: ""
+ Pressure Intensity Percent: 50
+ Pressure Intensity Toggle Mode: false
+ Pressure Intensity Deadzone: 0
+ Analog Limiter Button: ""
+ Analog Limiter Toggle Mode: false
+ Left Stick Multiplier: 100
+ Right Stick Multiplier: 100
+ Left Stick Deadzone: 0
+ Right Stick Deadzone: 0
+ Left Stick Anti-Deadzone: 0
+ Right Stick Anti-Deadzone: 0
+ Left Trigger Threshold: 0
+ Right Trigger Threshold: 0
+ Left Pad Squircling Factor: 8000
+ Right Pad Squircling Factor: 8000
+ Color Value R: 0
+ Color Value G: 0
+ Color Value B: 0
+ Blink LED when battery is below 20%: true
+ Use LED as a battery indicator: false
+ LED battery indicator brightness: 50
+ Player LED enabled: true
+ Large Vibration Motor Multiplier: 100
+ Small Vibration Motor Multiplier: 100
+ Switch Vibration Motors: false
+ Vibration Threshold: 63
+ Mouse Movement Mode: Relative
+ Mouse Deadzone X Axis: 60
+ Mouse Deadzone Y Axis: 60
+ Mouse Acceleration X Axis: 200
+ Mouse Acceleration Y Axis: 250
+ Left Stick Lerp Factor: 100
+ Right Stick Lerp Factor: 100
+ Analog Button Lerp Factor: 100
+ Trigger Lerp Factor: 100
+ Device Class Type: 0
+ Vendor ID: 0
+ Product ID: 0
+ Buddy Device: "Null"
+"""
+ for nplayer in range(3, 8)
+)
+
+# Sony pad GUIDs grouped by the RPCS3 handler / device prefix they map to.
+_DS3_GUIDS: Final = (
+ '030000004c0500006802000011010000',
+ '030000004c0500006802000011810000',
+ '050000004c0500006802000000800000',
+ '050000004c0500006802000000000000',
+)
+_DS4_GUIDS: Final = (
+ '030000004c050000c405000011810000',
+ '050000004c050000c405000000810000',
+ '030000004c050000cc09000011010000',
+ '050000004c050000cc09000000010000',
+ '030000004c050000cc09000011810000',
+ '050000004c050000cc09000000810000',
+ '030000004c050000a00b000011010000',
+ '030000004c050000a00b000011810000',
+)
+_DS5_GUIDS: Final = (
+ '030000004c050000e60c000011810000',
+ '050000004c050000e60c000000810000',
+)
+
+# guid -> (handler, device name prefix)
+_SONY_GUIDS: Final[dict[str, tuple[str, str]]] = {
+ **dict.fromkeys(_DS3_GUIDS, ('DualShock 3', 'DS3 Pad')),
+ **dict.fromkeys(_DS4_GUIDS, ('DualShock 4', 'DS4 Pad')),
+ **dict.fromkeys(_DS5_GUIDS, ('DualSense', 'DualSense Pad')),
+}
+
+# Static config block for DualShock / DualSense pads. The two vibration motor
+# entries are overwritten per pad (key order is preserved on overwrite).
+_DUALSHOCK_CONFIG: Final[dict[str, Any]] = {
+ 'Left Stick Left': 'LS X-',
+ 'Left Stick Down': 'LS Y-',
+ 'Left Stick Right': 'LS X+',
+ 'Left Stick Up': 'LS Y+',
+ 'Right Stick Left': 'RS X-',
+ 'Right Stick Down': 'RS Y-',
+ 'Right Stick Right': 'RS X+',
+ 'Right Stick Up': 'RS Y+',
+ 'Start': 'Options',
+ 'Select': 'Share',
+ 'PS Button': 'PS Button',
+ 'Square': 'Square',
+ 'Cross': 'Cross',
+ 'Circle': 'Circle',
+ 'Triangle': 'Triangle',
+ 'Left': 'Left',
+ 'Down': 'Down',
+ 'Right': 'Right',
+ 'Up': 'Up',
+ 'R1': 'R1',
+ 'R2': 'R2',
+ 'R3': 'R3',
+ 'L1': 'L1',
+ 'L2': 'L2',
+ 'L3': 'L3',
+ 'Motion Sensor X': {'Axis': '', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor Y': {'Axis': '', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor Z': {'Axis': '', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor G': {'Axis': '', 'Mirrored': False, 'Shift': 0},
+ 'Pressure Intensity Button': '',
+ 'Pressure Intensity Percent': 50,
+ 'Pressure Intensity Toggle Mode': False,
+ 'Left Stick Multiplier': 100,
+ 'Right Stick Multiplier': 100,
+ 'Left Stick Deadzone': 40,
+ 'Right Stick Deadzone': 40,
+ 'Left Trigger Threshold': 0,
+ 'Right Trigger Threshold': 0,
+ 'Left Pad Squircling Factor': 8000,
+ 'Right Pad Squircling Factor': 8000,
+ 'Color Value R': 0,
+ 'Color Value G': 0,
+ 'Color Value B': 20,
+ 'Blink LED when battery is below 20%': True,
+ 'Use LED as a battery indicator': False,
+ 'LED battery indicator brightness': 10,
+ 'Player LED enabled': True,
+ 'Enable Large Vibration Motor': 'true',
+ 'Enable Small Vibration Motor': 'true',
+ 'Switch Vibration Motors': False,
+ 'Mouse Movement Mode': 'Relative',
+ 'Mouse Deadzone X Axis': 60,
+ 'Mouse Deadzone Y Axis': 60,
+ 'Mouse Acceleration X Axis': 200,
+ 'Mouse Acceleration Y Axis': 250,
+ 'Left Stick Lerp Factor': 100,
+ 'Right Stick Lerp Factor': 100,
+ 'Analog Button Lerp Factor': 100,
+ 'Trigger Lerp Factor': 100,
+ 'Device Class Type': 0,
+ 'Vendor ID': 1356,
+ 'Product ID': 616,
+}
+
+# Evdev mapping: batocera input name -> (RPCS3 config name, event variations).
+# Each variation is (evdev event type, RPCS3 value name); the event type is
+# matched against the pad's input type (button / hat / axis).
+# from evdev_joystick_handler.h
+_EVDEV_INPUT_MAPPING: Final[dict[str, tuple[str, tuple[tuple[str, str], ...]]]] = {
+ 'up': ('Up', (('BTN_DPAD_UP', 'D-Pad Up'), ('ABS_HAT0Y', 'Hat0 Y-'))),
+ 'down': ('Down', (('BTN_DPAD_DOWN', 'D-Pad Down'), ('ABS_HAT0Y', 'Hat0 Y+'))),
+ 'left': ('Left', (('BTN_DPAD_LEFT', 'D-Pad Left'), ('ABS_HAT0X', 'Hat0 X-'))),
+ 'right': ('Right', (('BTN_DPAD_RIGHT', 'D-Pad Right'), ('ABS_HAT0X', 'Hat0 X+'))),
+ 'l2': ('L2', (('BTN_TL2', 'TL 2'), ('ABS_Z', 'LZ+'))),
+ 'r2': ('R2', (('BTN_TR2', 'TR 2'), ('ABS_RZ', 'RZ+'))),
+ 'a': ('Cross', (('BTN_A', 'A'),)),
+ 'b': ('Circle', (('BTN_B', 'B'),)),
+ 'x': ('Square', (('BTN_X', 'X'),)),
+ 'y': ('Triangle', (('BTN_Y', 'Y'),)),
+ 'joystick1up': ('Left Stick Up', (('ABS_Y', 'LY-'),)),
+ 'joystick1left': ('Left Stick Left', (('ABS_X', 'LX-'),)),
+ 'joystick2up': ('Right Stick Up', (('ABS_RY', 'RY-'),)),
+ 'joystick2left': ('Right Stick Left', (('ABS_RX', 'RX-'),)),
+}
+
+# Paired secondary axis written alongside the primary stick axis.
+_EVDEV_AXIS_PAIRS: Final = {
+ 'Left Stick Up': ('Left Stick Down', 'LY+'),
+ 'Left Stick Left': ('Left Stick Right', 'LX+'),
+ 'Right Stick Up': ('Right Stick Down', 'RY+'),
+ 'Right Stick Left': ('Right Stick Right', 'RX+'),
+}
+
+# Static tail for Evdev pads, appended after the dynamically mapped inputs.
+_EVDEV_CONFIG_TAIL: Final[dict[str, Any]] = {
+ 'R1': 'TR',
+ 'R3': 'Thumb R',
+ 'L1': 'TL',
+ 'L3': 'Thumb L',
+ 'Motion Sensor X': {'Axis': 'X', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor Y': {'Axis': 'Y', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor Z': {'Axis': 'Z', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor G': {'Axis': 'RY', 'Mirrored': False, 'Shift': 0},
+ 'Pressure Intensity Button': '',
+ 'Pressure Intensity Percent': 50,
+ 'Pressure Intensity Toggle Mode': False,
+ 'Left Stick Multiplier': 100,
+ 'Right Stick Multiplier': 100,
+ 'Left Stick Deadzone': 30,
+ 'Right Stick Deadzone': 30,
+ 'Left Trigger Threshold': 0,
+ 'Right Trigger Threshold': 0,
+ 'Left Pad Squircling Factor': 5000,
+ 'Right Pad Squircling Factor': 5000,
+ 'Color Value R': 0,
+ 'Color Value G': 0,
+ 'Color Value B': 0,
+ 'Blink LED when battery is below 20%': True,
+ 'Use LED as a battery indicator': False,
+ 'LED battery indicator brightness': 50,
+ 'Player LED enabled': True,
+ 'Enable Large Vibration Motor': 'true',
+ 'Enable Small Vibration Motor': 'true',
+ 'Switch Vibration Motors': False,
+ 'Mouse Movement Mode': 'Relative',
+ 'Mouse Deadzone X Axis': 60,
+ 'Mouse Deadzone Y Axis': 60,
+ 'Mouse Acceleration X Axis': 200,
+ 'Mouse Acceleration Y Axis': 250,
+ 'Left Stick Lerp Factor': 100,
+ 'Right Stick Lerp Factor': 100,
+ 'Analog Button Lerp Factor': 100,
+ 'Trigger Lerp Factor': 100,
+ 'Device Class Type': 0,
+ 'Vendor ID': 1356,
+ 'Product ID': 616,
+}
+
+# Static config block for the default SDL handler. The two vibration motor
+# entries are overwritten per pad (key order is preserved on overwrite).
+_SDL_CONFIG: Final[dict[str, Any]] = {
+ 'Left Stick Left': 'LS X-',
+ 'Left Stick Down': 'LS Y-',
+ 'Left Stick Right': 'LS X+',
+ 'Left Stick Up': 'LS Y+',
+ 'Right Stick Left': 'RS X-',
+ 'Right Stick Down': 'RS Y-',
+ 'Right Stick Right': 'RS X+',
+ 'Right Stick Up': 'RS Y+',
+ 'Start': 'Start',
+ 'Select': 'Back',
+ 'PS Button': 'Guide',
+ 'Square': 'West',
+ 'Cross': 'South',
+ 'Circle': 'East',
+ 'Triangle': 'North',
+ 'Left': 'Left',
+ 'Down': 'Down',
+ 'Right': 'Right',
+ 'Up': 'Up',
+ 'R1': 'RB',
+ 'R2': 'RT',
+ 'R3': 'RS',
+ 'L1': 'LB',
+ 'L2': 'LT',
+ 'L3': 'LS',
+ 'IR Nose': '',
+ 'IR Tail': '',
+ 'IR Left': '',
+ 'IR Right': '',
+ 'Tilt Left': '',
+ 'Tilt Right': '',
+ 'Motion Sensor X': {'Axis': 'X', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor Y': {'Axis': 'Y', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor Z': {'Axis': 'Z', 'Mirrored': False, 'Shift': 0},
+ 'Motion Sensor G': {'Axis': 'RY', 'Mirrored': False, 'Shift': 0},
+ 'Orientation Reset Button': '',
+ 'Orientation Enabled': False,
+ 'Pressure Intensity Button': '',
+ 'Pressure Intensity Percent': 50,
+ 'Pressure Intensity Toggle Mode': False,
+ 'Pressure Intensity Deadzone': 0,
+ 'Analog Limiter Button': '',
+ 'Analog Limiter Toggle Mode': False,
+ 'Left Stick Multiplier': 100,
+ 'Right Stick Multiplier': 100,
+ 'Left Stick Deadzone': 8000,
+ 'Right Stick Deadzone': 8000,
+ 'Left Stick Anti-Deadzone': 4259,
+ 'Right Stick Anti-Deadzone': 4259,
+ 'Left Trigger Threshold': 0,
+ 'Right Trigger Threshold': 0,
+ 'Left Pad Squircling Factor': 8000,
+ 'Right Pad Squircling Factor': 8000,
+ 'Color Value R': 0,
+ 'Color Value G': 0,
+ 'Color Value B': 20,
+ 'Blink LED when battery is below 20%': True,
+ 'Use LED as a battery indicator': False,
+ 'LED battery indicator brightness': 10,
+ 'Player LED enabled': True,
+ 'Enable Large Vibration Motor': 0,
+ 'Enable Small Vibration Motor': 0,
+ 'Switch Vibration Motors': False,
+ 'Mouse Movement Mode': 'Relative',
+ 'Mouse Deadzone X Axis': 60,
+ 'Mouse Deadzone Y Axis': 60,
+ 'Mouse Acceleration X Axis': 200,
+ 'Mouse Acceleration Y Axis': 250,
+ 'Left Stick Lerp Factor': 100,
+ 'Right Stick Lerp Factor': 100,
+ 'Analog Button Lerp Factor': 100,
+ 'Trigger Lerp Factor': 100,
+ 'Device Class Type': 0,
+ 'Vendor ID': 1356,
+ 'Product ID': 616,
+}
+
+
+def generate_controllers_config(
+ config_dir: Path, config: SystemConfig, controllers: Controllers, /, *, keyboard: bool = False
+) -> None:
+ input_dir = config_dir / 'input_configs' / 'global'
+ input_dir.mkdir(parents=True, exist_ok=True)
+
+ # Lightgun arcade titles are driven through the keyboard (two players on one keyboard).
+ if keyboard:
+ (input_dir / 'Default.yml').write_text(_ARCADE_KEYBOARD_CONFIG)
+ return
+
+ # Per-handler device counters: RPCS3 appends a unique index per device name.
+ sony_counts: dict[str, int] = {}
+ sdl_counts: dict[str, int] = {}
+
+ controllers_config: dict[str, Any] = {}
+ for nplayer, pad in enumerate(controllers[:7], start=1):
+ _logger.debug('Controller #%s - %s', nplayer, pad.guid)
+ controller_type = config.get(f'rpcs3_controller{nplayer}')
+ rumble = config.get_bool(f'rpcs3_rumble{nplayer}', True)
+
+ # DualShock / DualSense
+ if pad.guid in _SONY_GUIDS and controller_type == 'Sony':
+ _logger.debug('*** Using DualShock / DualSense configuration ***')
+ handler, prefix = _SONY_GUIDS[pad.guid]
+ count = sony_counts[prefix] = sony_counts.get(prefix, 0) + 1
+
+ pad_config = dict(_DUALSHOCK_CONFIG)
+ pad_config['Enable Large Vibration Motor'] = rumble
+ pad_config['Enable Small Vibration Motor'] = rumble
+
+ controllers_config[f'Player {nplayer} Input'] = {
+ 'Handler': handler,
+ 'Device': f'{prefix} #{count}',
+ 'Config': pad_config,
+ 'Buddy Device': '',
+ }
+ # Evdev
+ elif controller_type == 'Evdev':
+ _logger.debug('*** Using EVDEV configuration ***')
+ pad_config: dict[str, Any] = {'Start': 'Start', 'Select': 'Select', 'PS Button': 'Mode'}
+
+ for input in pad.inputs.values():
+ mapping = _EVDEV_INPUT_MAPPING.get(input.name)
+ if mapping is None:
+ continue
+
+ config_name, event_variations = mapping
+ for event_type, value_name in event_variations:
+ if ('BTN' in event_type and input.type == 'button') or (
+ 'HAT' in event_type and input.type == 'hat'
+ ):
+ pad_config[config_name] = value_name
+ elif 'ABS' in event_type and input.type == 'axis':
+ pad_config[config_name] = value_name
+ # write the matching secondary stick axis as well
+ if (pair := _EVDEV_AXIS_PAIRS.get(config_name)) is not None:
+ pad_config[pair[0]] = pair[1]
+
+ pad_config.update(_EVDEV_CONFIG_TAIL)
+ pad_config['Enable Large Vibration Motor'] = rumble
+ pad_config['Enable Small Vibration Motor'] = rumble
+
+ controllers_config[f'Player {nplayer} Input'] = {
+ 'Handler': 'Evdev',
+ 'Device': pad.device_path,
+ 'Config': pad_config,
+ 'Buddy Device': '',
+ }
+ # Default SDL handler
+ else:
+ _logger.debug('*** Using default SDL3 configuration ***')
+ # workaround controllers with commas in their name - like Nintendo
+ ctrlname = pad.real_name.replace(',', '.')
+ count = sdl_counts[ctrlname] = sdl_counts.get(ctrlname, 0) + 1
+
+ pad_config = dict(_SDL_CONFIG)
+ motor = 100 if rumble else 0
+ pad_config['Enable Large Vibration Motor'] = motor
+ pad_config['Enable Small Vibration Motor'] = motor
+
+ controllers_config[f'Player {nplayer} Input'] = {
+ 'Handler': 'SDL',
+ 'Device': f'{ctrlname} {count}',
+ 'Config': pad_config,
+ 'Buddy Device': '',
+ }
+
+ safe_dump_yaml12(controllers_config, input_dir / 'Default.yml')
diff --git a/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/emulator.py b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/emulator.py
new file mode 100644
index 00000000000..632a37259a5
--- /dev/null
+++ b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/emulator.py
@@ -0,0 +1,1007 @@
+from __future__ import annotations
+
+import asyncio
+import filecmp
+import logging
+import re
+import shutil
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Final, Self, cast
+
+import aiohttp
+from ruamel.yaml import YAML
+
+from batocera_common import vulkan
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.dict import merge
+from batocera_common.fs import directory_differences
+from batocera_common.paths import BIOS, CACHE, CONFIGS
+from batocera_common.yaml import safe_dump_yaml12, safe_load_yaml12
+from batocera_launch import BatoceraException, Command, Emulator, HotkeysContext
+from batocera_launch.paths import configure_emulator
+
+from .controllers import generate_controllers_config
+from .sfo import SFO
+
+if TYPE_CHECKING:
+ from types import TracebackType
+
+_logger = logging.getLogger(__name__)
+
+_BIN_PATH: Final = Path('/usr/bin/rpcs3')
+_OVERCOMMIT_PATH: Final = Path('/proc/sys/vm/overcommit_memory')
+
+# USB device tuning for the arcade PS3 titles (System 357/369, Taiko, ...) shipped as a
+# PSN squashfs. These all share the SCEEXE000 title-id, so they cannot be told apart by
+# their dev_hdd0/game/ directory; instead they are matched on the PARAM.SFO TITLE.
+# Each entry maps a /dev_usbNNN slot to the emulated USB I/O board id (Serial/VID/PID);
+# the slot Path is filled at runtime from the squashfs overlay when it ships that folder.
+# NOTE: keys must be the exact PARAM.SFO TITLE. Only "DarkEscape" is confirmed so far;
+# the others are keyed by their working name pending the real TITLE value.
+_ARCADE_USB_CONFIG: Final[dict[str, dict[str, dict[str, str]]]] = {
+ 'DarkEscape': {
+ '/dev_usb000': {'Serial': '268611070000', 'VID': '0b9a', 'PID': '0c00'},
+ },
+ 'TEKKEN6': {
+ '/dev_usb000': {'Serial': '76C0D0000000', 'VID': '0693', 'PID': '0026'},
+ '/dev_usb007': {'Serial': '76C0D0003038', 'VID': '0693', 'PID': '0026', 'Path': ''},
+ },
+ 'TEKKEN6BR': {
+ '/dev_usb000': {'Serial': '026450800000', 'VID': '0693', 'PID': '0026'},
+ '/dev_usb007': {'Serial': '76C0D0003038', 'VID': '0693', 'PID': '0026', 'Path': ''},
+ },
+ 'RazingStorm': {
+ '/dev_usb000': {'Serial': '026391000000', 'VID': '0693', 'PID': '0026'},
+ },
+ 'Sailor zombie': {
+ '/dev_usb000': {'Serial': '271711170000', 'VID': '0b9a', 'PID': '0c10'},
+ },
+ 'DZB3': {
+ '/dev_usb000': {'Serial': '267210000000', 'VID': '0B9A', 'PID': '0C00'},
+ },
+ 'Deadstorm Pirates Special Edition': {
+ '/dev_usb000': {'Serial': '272311000000', 'VID': '0B9A', 'PID': '0C00'},
+ },
+ # Tekken Tag Tournament 2 and its Unlimited revision share the same TITLE.
+ 'TEKKEN TAG TOURNAMENT 2': {
+ '/dev_usb000': {'Serial': '267910000000', 'VID': '0B9A', 'PID': '0C00'},
+ },
+ # Taiko no Tatsujin variants all share the same I/O board ids.
+ **{
+ taiko: {
+ '/dev_usb000': {'Serial': '000000000000', 'VID': '13fe', 'PID': '4100'},
+ '/dev_usb001': {'Serial': '268411060021', 'VID': '0b9a', 'PID': '0c00'},
+ }
+ for taiko in (
+ 'Taiko no Tatsujin', # Sorairo Version
+ 'Taiko no Tatsujin(S101)', # Blue Version
+ 'Taiko no Tatsujin(S111)', # Green Version
+ 'Taiko no Tatsujin(ST41)', # Momoiro Version
+ 'Taiko no Tatsujin(ST48)', # Wadaiko Master
+ 'Taiko no Tatsujin(ST51)', # Kimidori Version
+ 'Taiko no Tatsujin(ST61)', # Murasaki Version
+ 'Taiko no Tatsujin(ST71)', # White Version
+ 'Taiko no Tatsujin(ST87)', # Red Version
+ 'Taiko no Tatsujin(ST91)', # Yellow Version
+ )
+ },
+}
+
+# RPCS3 game patches required by the arcade titles, merged into the user's
+# patches/imported_patch.yml. New PPU entries are added without overwriting any the
+# user may already have.
+_ARCADE_PATCHES: Final[dict[str, Any]] = {
+ 'Version': 1.2,
+ 'PPU-31faa92273d6269ea41b4d158e443f3d0e4174a7': {
+ 'Bypass Security Checks (Green)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x004b69e8', '0x38600000']],
+ },
+ },
+ 'PPU-17fe05b18e1e6b40d5387418529be44fdf5e39a3': {
+ 'Bypass Security Checks (Wadaiko)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x00347060', '0x38600000']],
+ },
+ },
+ 'PPU-d3af4341bb24860b223158db5b0093c87bf91d90': {
+ 'Bypass Security Checks (Momoiro)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x0032d910', '0x38600000']],
+ },
+ },
+ 'PPU-f20ba6cf299b3873d7007f8a4f1e8efd2319ade4': {
+ 'Bypass Security Checks (Murasaki)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x0039d238', '0x38600000']],
+ },
+ },
+ 'PPU-2e6b644196e1fa089efd4d87db9c43fe81e81263': {
+ 'Bypass Security Checks (Red)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x0041ec50', '0x38600000']],
+ },
+ },
+ 'PPU-58f3c6e971e82e67b0c69cfdd362e0ca60ce92a4': {
+ 'Bypass Security Checks (White)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x0041f738', '0x38600000']],
+ },
+ },
+ 'PPU-de4bd316b3e0a94b1620dc0b8c663f3ff865f409': {
+ 'Bypass Security Checks (Yellow)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x00456638', '0x38600000']],
+ },
+ },
+ 'PPU-0ac0a218b038d56c015bc33018f2875d406547e8': {
+ 'Bypass Security Checks (Blue)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x00474b78', '0x38600000']],
+ },
+ },
+ 'PPU-0b8c2d5f0d1819cdaafaa297da508065b7b00edb': {
+ 'Bypass Security Checks (Kimidori)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x0035b4c8', '0x38600000']],
+ },
+ },
+ 'PPU-78cfd074e799c0aeaf5e4241c597f741ba10bd1a': {
+ 'Bypass Security Checks (KATSU-DON)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x00299e10', '0x38600000']],
+ },
+ },
+ 'PPU-38935c6c0a4cc67b8908ed312fafaa8a605a18e4': {
+ 'Bypass Security Checks (Sorairo)': {
+ 'Games': {'Taiko no Tatsujin': {'SCEEXE000': ['01.00']}},
+ 'Author': 'GetzeAve',
+ 'Notes': 'Yes.',
+ 'Patch Version': 1.0,
+ 'Patch': [['be32', '0x002d5098', '0x38600000']],
+ },
+ },
+}
+
+# Patch enable state required by the Taiko titles, merged into patches/patch_config.yml.
+_TAIKO_PATCH_CONFIG: Final[dict[str, Any]] = {
+ 'PPU-38935c6c0a4cc67b8908ed312fafaa8a605a18e4': {
+ 'Bypass Security Checks (Sorairo)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-78cfd074e799c0aeaf5e4241c597f741ba10bd1a': {
+ 'Bypass Security Checks (KATSU-DON)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-2e6b644196e1fa089efd4d87db9c43fe81e81263': {
+ 'Bypass Security Checks (Red)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-d3af4341bb24860b223158db5b0093c87bf91d90': {
+ 'Bypass Security Checks (Momoiro)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-17fe05b18e1e6b40d5387418529be44fdf5e39a3': {
+ 'Bypass Security Checks (Wadaiko)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-31faa92273d6269ea41b4d158e443f3d0e4174a7': {
+ 'Bypass Security Checks (Green)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-f20ba6cf299b3873d7007f8a4f1e8efd2319ade4': {
+ 'Bypass Security Checks (Murasaki)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-de4bd316b3e0a94b1620dc0b8c663f3ff865f409': {
+ 'Bypass Security Checks (Yellow)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-0ac0a218b038d56c015bc33018f2875d406547e8': {
+ 'Bypass Security Checks (Blue)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-58f3c6e971e82e67b0c69cfdd362e0ca60ce92a4': {
+ 'Bypass Security Checks (White)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+ 'PPU-0b8c2d5f0d1819cdaafaa297da508065b7b00edb': {
+ 'Bypass Security Checks (Kimidori)': {
+ 'Taiko no Tatsujin': {'SCEEXE000': {'01.00': {'Enabled': True}}},
+ },
+ },
+}
+
+# USIO emulated arcade I/O board button mapping (Taiko / Tekken / card reader), written
+# verbatim as the global usio.yml for arcade titles.
+_USIO_CONFIG: Final = """\
+Player 1:
+ Test: R3
+ Coin: Select
+ Service: L3
+ Enter/Start: Start
+ Up: D-Pad Up
+ Down: D-Pad Down
+ Left: D-Pad Left
+ Right: D-Pad Right
+ Taiko Hit Side Left: Square
+ Taiko Hit Side Right: Circle
+ Taiko Hit Center Left: Triangle
+ Taiko Hit Center Right: Cross
+ Tekken Button 1: Square
+ Tekken Button 2: Triangle
+ Tekken Button 3: Cross
+ Tekken Button 4: Circle
+ Tekken Button 5: R1
+ Card Tapping: L1
+Player 2:
+ Test: R3
+ Coin: Select
+ Service: L3
+ Enter/Start: Start
+ Up: D-Pad Up
+ Down: D-Pad Down
+ Left: D-Pad Left
+ Right: D-Pad Right
+ Taiko Hit Side Left: Square
+ Taiko Hit Side Right: Circle
+ Taiko Hit Center Left: Triangle
+ Taiko Hit Center Right: Cross
+ Tekken Button 1: Square
+ Tekken Button 2: Triangle
+ Tekken Button 3: Cross
+ Tekken Button 4: Circle
+ Tekken Button 5: R1
+ Card Tapping: L1
+Player 3:
+ Test: Select
+ Coin: L3
+ Service: R3
+ Enter/Start: Start
+ Up: D-Pad Up
+ Down: D-Pad Down
+ Left: D-Pad Left
+ Right: D-Pad Right
+ Taiko Hit Side Left: Square
+ Taiko Hit Side Right: Circle
+ Taiko Hit Center Left: Triangle
+ Taiko Hit Center Right: Cross
+ Tekken Button 1: Square
+ Tekken Button 2: Triangle
+ Tekken Button 3: Cross
+ Tekken Button 4: Circle
+ Tekken Button 5: R1
+ Card Tapping: L1
+Player 4:
+ Test: Select
+ Coin: L3
+ Service: R3
+ Enter/Start: Start
+ Up: D-Pad Up
+ Down: D-Pad Down
+ Left: D-Pad Left
+ Right: D-Pad Right
+ Taiko Hit Side Left: Square
+ Taiko Hit Side Right: Circle
+ Taiko Hit Center Left: Triangle
+ Taiko Hit Center Right: Cross
+ Tekken Button 1: Square
+ Tekken Button 2: Triangle
+ Tekken Button 3: Cross
+ Tekken Button 4: Circle
+ Tekken Button 5: R1
+ Card Tapping: L1
+"""
+
+
+def _get_firmware_version(config_dir: Path, /) -> str | None:
+ try:
+ with (config_dir / 'dev_flash' / 'vsh' / 'etc' / 'version.txt').open('r') as stream:
+ lines = stream.readlines()
+ for line in lines:
+ matches = re.match('^release:(.*):', line)
+ if matches:
+ return matches[1]
+ except Exception:
+ return None
+ return None
+
+
+def _migrate_dev_hdd0(config_dir: Path, hdd0_dir: Path, /) -> None:
+ legacy_dev_hdd0 = config_dir / 'dev_hdd0'
+ if not legacy_dev_hdd0.exists():
+ # New install or fully migrated: nothing to do
+ return
+
+ if hdd0_dir.exists():
+ # Partial or failed migration: leave it alone
+ _logger.warning('Skipping RPCS3 dev_hdd0 migration: target directory already exists at %s', hdd0_dir)
+ return
+
+ hdd0_dir.parent.mkdir(parents=True, exist_ok=True)
+
+ try:
+ shutil.copytree(legacy_dev_hdd0, hdd0_dir)
+ except Exception:
+ _logger.exception('Failed to copy RPCS3 dev_hdd0 from %s to %s', legacy_dev_hdd0, hdd0_dir)
+ return
+
+ _logger.debug('Successfully copied RPCS3 dev_hdd0 from %s to %s', legacy_dev_hdd0, hdd0_dir)
+
+ differences = directory_differences(legacy_dev_hdd0, hdd0_dir)
+ if differences:
+ _logger.error('RPCS3 dev_hdd0 migration verification failed:\n%s', differences.report())
+ return
+
+ _logger.debug('Verified RPCS3 dev_hdd0 migration from %s to %s', legacy_dev_hdd0, hdd0_dir)
+
+ shutil.rmtree(legacy_dev_hdd0)
+
+ _logger.debug('Completed RPCS3 dev_hdd0 migration from %s to %s', legacy_dev_hdd0, hdd0_dir)
+
+
+def _deep_merge_missing(destination: dict[str, Any], source: Mapping[str, Any], /) -> bool:
+ changed = False
+
+ for key, value in source.items():
+ if key not in destination:
+ destination[key] = value
+ changed = True
+
+ elif isinstance(destination[key], dict) and isinstance(value, Mapping):
+ changed |= _deep_merge_missing(destination[key], cast('Mapping[str, Any]', value))
+
+ return changed
+
+
+def _merge_patch_config(config_file: Path, data: Mapping[str, Any], /) -> None:
+ # Create the target patch file from data if absent, otherwise merge in only the keys
+ # not yet present (preserving the user's own patches and enable/disable state).
+
+ yaml = YAML() # round-trip: preserve the user's file formatting and the hex literals
+
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ if not config_file.is_file():
+ yaml.dump(data, config_file) # pyright: ignore
+ return
+
+ with config_file.open() as f:
+ existing = cast('dict[str, Any]', yaml.load(f) or {}) # pyright: ignore
+
+ if _deep_merge_missing(existing, data):
+ yaml.dump(existing, config_file) # pyright: ignore
+
+
+async def _fetch_compatibility_database(target_path: Path, /) -> None:
+ """Download RPCS3 compatibility database to /tmp/rpcs3, compare, and update if changed."""
+ tmp_dir = Path('/tmp/rpcs3')
+ tmp_file = tmp_dir / target_path.name
+
+ try:
+ tmp_dir.mkdir(parents=True, exist_ok=True)
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+
+ async with (
+ aiohttp.ClientSession() as session,
+ session.get(
+ 'https://api.rpcs3.net/config/?api=v1',
+ headers={'User-Agent': 'RPCS3/Batocera'},
+ timeout=aiohttp.ClientTimeout(total=15),
+ ) as response,
+ ):
+ tmp_file.write_bytes(await response.read())
+
+ # If destination doesn't exist or content has changed, overwrite it
+ if not target_path.exists() or not filecmp.cmp(tmp_file, target_path, shallow=False):
+ shutil.move(tmp_file, target_path)
+ _logger.debug('Updated RPCS3 compatibility database at %s', target_path)
+ else:
+ _logger.debug('RPCS3 compatibility database is already up to date')
+ tmp_file.unlink(missing_ok=True)
+
+ except Exception as e:
+ _logger.debug('Could not update RPCS3 compatibility database: %s', e)
+ if tmp_file.exists():
+ try:
+ tmp_file.unlink()
+ except OSError:
+ pass
+
+
+@dataclass(slots=True)
+class RPCS3Command(Command):
+ async def run(self) -> int:
+ original_overcommit: str | None = None
+
+ try:
+ original_overcommit = _OVERCOMMIT_PATH.read_text()
+ _OVERCOMMIT_PATH.write_text('1\n')
+ except Exception:
+ _logger.warning('Failed to set vm.overcommit_memory=1', exc_info=True)
+
+ try:
+ return await super().run()
+ finally:
+ if original_overcommit is not None:
+ try:
+ _OVERCOMMIT_PATH.write_text(original_overcommit)
+ except Exception:
+ _logger.warning('Failed to restore vm.overcommit_memory', exc_info=True)
+
+
+@cached_dataclass
+class RPCS3(Emulator):
+ compatibility_database_task: asyncio.Task[None] = field(init=False)
+
+ async def __aenter__(self) -> Self:
+ # Start downloading the compatibility database ASAP in the background
+ self.compatibility_database_task = asyncio.create_task(
+ _fetch_compatibility_database(self.config_dir / 'GuiConfigs' / 'config_database.dat')
+ )
+
+ try:
+ return await super().__aenter__()
+ except BaseException:
+ # Cancel the task if the context manager fails to enter (e.g. KeyboardInterrupt)
+ self.compatibility_database_task.cancel()
+ try:
+ # await the task and suppress the CancelledError to ensure aiohttp cleanup happens
+ await self.compatibility_database_task
+ except asyncio.CancelledError:
+ pass
+
+ raise
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: TracebackType | None,
+ /,
+ ) -> bool | None:
+ # Cancel the task if the context manager exits
+ self.compatibility_database_task.cancel()
+ try:
+ # await the task and suppress the CancelledError to ensure aiohttp cleanup happens
+ await self.compatibility_database_task
+ except asyncio.CancelledError:
+ pass
+
+ return await super().__aexit__(exc_type, exc_value, traceback)
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'rpcs3',
+ 'keys': {
+ 'exit': '/usr/bin/rpcs3-exit',
+ 'menu': ['KEY_LEFTSHIFT', 'KEY_F10'],
+ 'pause': ['KEY_LEFTCTRL', 'KEY_P'],
+ },
+ }
+
+ @cached_property
+ def hdd0_dir(self) -> Path:
+ return self.saves_dir / 'rpcs3' / 'dev_hdd0'
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ @property
+ def needs_overlayfs(self) -> bool:
+ return True
+
+ @property
+ def closest_screen_ratio(self) -> str:
+ screen_ratio = self.resolution.width / self.resolution.height
+ return '4:3' if screen_ratio < 1.6 else '16:9'
+
+ @cached_property
+ def rom_dev_hdd0(self) -> Path:
+ return self.rom / 'dev_hdd0'
+
+ @cached_property
+ def rom_dev_hdd1(self) -> Path:
+ return self.rom / 'dev_hdd1'
+
+ @cached_property
+ def rom_game_dir(self) -> Path:
+ return self.rom_dev_hdd0 / 'game'
+
+ @cached_property
+ def is_psn_squashfs(self) -> bool:
+ # Detect PSN game packed as a squashfs: emulatorlauncher has already mounted the
+ # squashfs and (via writesToRom=True) created a writable overlayfs, so rom is
+ # /var/run/overlays/ mirroring the dev_hdd0 layout.
+ return self.rom.is_dir() and str(self.rom).startswith('/var/run/') and self.rom_game_dir.is_dir()
+
+ @cached_property
+ def arcade_game_title(self) -> str | None:
+ # Match an arcade PSN squashfs title via its PARAM.SFO TITLE (a key of _ARCADE_USB_CONFIG).
+ # Arcade titles all ship under the same SCEEXE000 title-id.
+ if not self.is_psn_squashfs:
+ return None
+
+ if (sfo_file := self.rom_game_dir / 'SCEEXE000' / 'PARAM.SFO').is_file():
+ sfo = SFO.from_path(sfo_file)
+ title = sfo.TITLE
+
+ if title and title in _ARCADE_USB_CONFIG:
+ _logger.debug("Matched RPCS3 arcade title '%s'", title)
+ return title
+
+ return None
+
+ def _generate_vfs_config(self, /) -> None:
+ arcade_usb_config = _ARCADE_USB_CONFIG.get(self.arcade_game_title or '', {})
+
+ dev_hdd0 = f'{self.rom_dev_hdd0 if self.is_psn_squashfs else self.hdd0_dir}/'
+ # For a PSN squashfs, redirect /dev_hdd1/ to the overlay when it ships one.
+ dev_hdd1 = (
+ f'{self.rom_dev_hdd1 if self.is_psn_squashfs and self.rom_dev_hdd1.is_dir() else "$(EmulatorDir)dev_hdd1"}/'
+ )
+
+ # Build the full /dev_usb***/ block (slots 000-007). Every tuned slot (one carrying
+ # an emulated USB I/O board Serial/VID/PID) reads its data from the overlay's single
+ # dev_usb000 folder, so its Path defaults there; a slot may override "Path" (e.g. ""
+ # for a pure passthrough device). Untuned dev_usb000 keeps the RPCS3 default mount
+ # (or the overlay folder if shipped) and the rest stay empty.
+ overlay_usb000_dir = self.rom / 'dev_usb000'
+ overlay_usb000 = f'{overlay_usb000_dir}/'
+
+ if arcade_usb_config and self.is_psn_squashfs:
+ overlay_usb000_dir.mkdir(parents=True, exist_ok=True)
+
+ usb_config: dict[str, dict[str, str]] = {}
+
+ for index in range(8):
+ slot = f'/dev_usb00{index}'
+ slot_usb_config = arcade_usb_config.get(slot, {})
+ usb_dir = self.rom / f'dev_usb00{index}'
+
+ if slot_usb_config:
+ path = slot_usb_config.get('Path', overlay_usb000 if self.is_psn_squashfs else '')
+ elif self.is_psn_squashfs and usb_dir.is_dir():
+ path = f'{usb_dir}/'
+ elif not index:
+ path = '$(EmulatorDir)dev_usb000/'
+ else:
+ path = ''
+
+ usb_config[slot] = {
+ 'Path': path,
+ 'Serial': slot_usb_config.get('Serial', ''),
+ 'VID': slot_usb_config.get('VID', ''),
+ 'PID': slot_usb_config.get('PID', ''),
+ }
+
+ safe_dump_yaml12(
+ {
+ '$(EmulatorDir)': '',
+ '/dev_hdd0/': dev_hdd0,
+ '/dev_hdd1/': dev_hdd1,
+ '/dev_flash/': '$(EmulatorDir)dev_flash/',
+ '/dev_flash2/': '$(EmulatorDir)dev_flash2/',
+ '/dev_flash3/': '$(EmulatorDir)dev_flash3/',
+ '/dev_bdvd/': '$(EmulatorDir)dev_bdvd/',
+ '/games/': '$(EmulatorDir)games/',
+ '/app_home/': '',
+ '/dev_usb***/': usb_config,
+ },
+ self.config_dir / 'vfs.yml',
+ )
+
+ def _generate_patch_config(self, /) -> None:
+ # arcade titles need their game patches imported and the USIO board input mapping;
+ # Taiko titles also need the matching patch enable state
+
+ if self.arcade_game_title:
+ _merge_patch_config(self.config_dir / 'patches' / 'imported_patch.yml', _ARCADE_PATCHES)
+ (self.config_dir / 'usio.yml').write_text(_USIO_CONFIG, encoding='utf-8')
+
+ if self.arcade_game_title.startswith('Taiko no Tatsujin'):
+ _merge_patch_config(self.config_dir / 'patch_config.yml', _TAIKO_PATCH_CONFIG)
+
+ async def configure(self) -> Command:
+ _migrate_dev_hdd0(self.config_dir, self.hdd0_dir)
+
+ self.hdd0_dir.mkdir(parents=True, exist_ok=True)
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ generate_controllers_config(
+ self.config_dir, self.config, self.controllers, keyboard=bool(self.arcade_game_title)
+ )
+
+ # Taking care of the CurrentSettings.ini file
+ current_settings_path = self.config_dir / 'GuiConfigs' / 'CurrentSettings.ini'
+ current_settings_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Generates CurrentSettings.ini with values to disable prompts on first run
+ current_settings = CaseSensitiveConfigParser(interpolation=None)
+ if current_settings_path.exists():
+ current_settings.read(current_settings_path)
+
+ # Sets Gui Settings to close completely and disables some popups
+ if not current_settings.has_section('main_window'):
+ current_settings.add_section('main_window')
+
+ current_settings.set('main_window', 'confirmationBoxExitGame', 'false')
+ current_settings.set('main_window', 'infoBoxEnabledInstallPUP', 'false')
+ current_settings.set('main_window', 'infoBoxEnabledWelcome', 'false')
+
+ with current_settings_path.open('w') as configfile:
+ current_settings.write(configfile)
+
+ config_file_path = self.config_dir / 'config.yml'
+
+ # Generate a default config if it doesn't exist otherwise just open the existing
+ rpcs3_yml_config: dict[str, dict[str, Any]] = {}
+ if config_file_path.is_file():
+ rpcs3_yml_config = safe_load_yaml12(config_file_path, dict[str, dict[str, Any]]) or {}
+
+ # VFS is no longer stored in config.yml: RPCS3 reads it from a dedicated vfs.yml
+ # file. Drop any stale VFS section that older versions may have written here.
+ rpcs3_yml_config.pop('VFS', None)
+
+ # Add Nodes if not in the file
+ if 'Core' not in rpcs3_yml_config:
+ rpcs3_yml_config['Core'] = {}
+ if 'Video' not in rpcs3_yml_config:
+ rpcs3_yml_config['Video'] = {}
+ if 'Audio' not in rpcs3_yml_config:
+ rpcs3_yml_config['Audio'] = {}
+ if 'Input/Output' not in rpcs3_yml_config:
+ rpcs3_yml_config['Input/Output'] = {}
+ if 'System' not in rpcs3_yml_config:
+ rpcs3_yml_config['System'] = {}
+ if 'Net' not in rpcs3_yml_config:
+ rpcs3_yml_config['Net'] = {}
+ if 'Savestate' not in rpcs3_yml_config:
+ rpcs3_yml_config['Savestate'] = {}
+ if 'Miscellaneous' not in rpcs3_yml_config:
+ rpcs3_yml_config['Miscellaneous'] = {}
+ if 'Log' not in rpcs3_yml_config:
+ rpcs3_yml_config['Log'] = {}
+
+ vulkan_info = await vulkan.get_vulkan_info()
+ backend = self.config.get('rpcs3_gfxbackend', 'Vulkan') if vulkan_info else 'OpenGL'
+
+ if vulkan_info:
+ _logger.debug('Vulkan driver is available on the system.')
+
+ if backend == 'OpenGL':
+ _logger.debug('User selected OpenGL')
+ else:
+ _logger.debug('Vulkan driver is not available on the system. Falling back to OpenGL')
+
+ match self.config.get('rpcs3_framelimit'):
+ case None:
+ frame_limit = 'Auto'
+ second_frame_limit = 0
+ # Check for valid Frame Limit value, if it's not a Frame Limit value apply to Second Frame Limit
+ case (
+ 'Off'
+ | '30'
+ | '50'
+ | '59.94'
+ | '60'
+ | '120'
+ | 'Display'
+ | 'Auto'
+ | 'PS3 Native'
+ | 'Infinite' as rpcs3_framelimit
+ ):
+ frame_limit = rpcs3_framelimit
+ second_frame_limit = 0
+ case _ as rpcs3_framelimit:
+ try:
+ frame_limit = 'Off'
+ second_frame_limit = float(rpcs3_framelimit)
+ except Exception:
+ frame_limit = 'Auto'
+ second_frame_limit = 0
+
+ match self.config.get('rpcs3_zcull'):
+ case 'Approximate':
+ accurate_zcull_stats = False
+ relaxed_zcull_sync = False
+ disable_zcull_occlusion_queries = False
+ case 'Relaxed':
+ accurate_zcull_stats = False
+ relaxed_zcull_sync = True
+ disable_zcull_occlusion_queries = False
+ case 'Disable':
+ accurate_zcull_stats = False
+ relaxed_zcull_sync = False
+ disable_zcull_occlusion_queries = True
+ case _:
+ accurate_zcull_stats = True
+ relaxed_zcull_sync = False
+ disable_zcull_occlusion_queries = False
+
+ time_stretch = self.config.get_bool('rpcs3_timestretch')
+
+ config: dict[str, Any] = {
+ 'Core': {
+ # Set the PPU Decoder based on config
+ 'PPU Decoder': self.config.get('rpcs3_ppudecoder', 'Recompiler (LLVM)'),
+ # Set the SPU Decoder based on config
+ 'SPU Decoder': self.config.get('rpcs3_spudecoder', 'Recompiler (LLVM)'),
+ # Set the SPU XFloat Accuracy based on config
+ 'SPU XFloat Accuracy': self.config.get('rpcs3_spuxfloataccuracy', 'Approximate'),
+ # Force to True for now to account for updates where exiting config file present. (True results in less stutter when a SPU module is in cache)
+ 'SPU Cache': True,
+ # Preferred SPU Threads
+ 'Preferred SPU Threads': self.config.get_int('rpcs3_sputhreads', 0),
+ # SPU Loop Detection
+ 'SPU loop detection': self.config.get_bool('rpcs3_spuloopdetection'),
+ # SPU Block Size
+ 'SPU Block Size': self.config.get('rpcs3_spublocksize', 'Safe'),
+ # Thread Scheduler Mode
+ 'Thread Scheduler Mode': self.config.get('rpcs3_thread_scheduler', 'Operating System'),
+ # Clocks scale
+ 'Clocks scale': self.config.get_int('rpcs3_clocks_scale', 100),
+ # Max Power Saving CPU-Preemptions
+ # values are maximum yields per frame threshold
+ 'Max CPU Preempt Count': self.config.get_int('rpcs3_maxcpu_preemptcount', 0),
+ # Sleep Timers Accuracy
+ 'Sleep Timers Accuracy': self.config.get('rpcs3_sleep_timers_accuracy', 'As Host'),
+ # RSX FIFO Fetch Accuracy
+ 'RSX FIFO Fetch Accuracy': self.config.get('rpcs3_rsx_fifo', 'Atomic'),
+ },
+ 'Video': {
+ 'Renderer': backend,
+ # System aspect ratio (the setting in the PS3 system itself, not the displayed ratio) a.k.a. TV mode.
+ # If not set, see if the screen ratio is closer to 4:3 or 16:9 and pick that.
+ 'Aspect ratio': self.config.get('rpcs3_ratio') or self.closest_screen_ratio,
+ # Shader compilation
+ 'Shader Mode': self.config.get('rpcs3_shadermode', 'Async with Shader Interpreter'),
+ # Vsync
+ 'VSync Mode': self.config.get('rpcs3_vsync', 'Disabled'),
+ # Stretch to display area
+ 'Stretch To Display Area': self.config.get_bool('rpcs3_stretchdisplay'),
+ # Frame Limit
+ # Frame limit checks for specific values("Auto", "Off", "30", "50", "59.94", "60", "120", "Display", "PS3 Native", "Infinite")
+ # Second Frame Limit can be any float/integer. 0 = disabled.
+ 'Frame limit': frame_limit,
+ 'Second Frame Limit': second_frame_limit,
+ # Write Color Buffers
+ 'Write Color Buffers': self.config.get_bool('rpcs3_colorbuffers')
+ or self.arcade_game_title == 'Deadstorm Pirates Special Edition',
+ # Read Color Buffers
+ 'Read Color Buffers': self.config.get_bool('rpcs3_read_colorbuffers'),
+ # Disable Vertex Cache
+ 'Disable Vertex Cache': self.config.get_bool('rpcs3_vertexcache'),
+ # Anisotropic Filtering
+ 'Anisotropic Filter Override': self.config.get_int('rpcs3_anisotropic', 0),
+ # MSAA
+ 'MSAA': self.config.get('rpcs3_aa', 'Auto'),
+ # ZCULL
+ 'Accurate ZCULL stats': accurate_zcull_stats,
+ 'Relaxed ZCULL Sync': relaxed_zcull_sync,
+ 'Disable ZCull Occlusion Queries': disable_zcull_occlusion_queries,
+ # Shader Precision
+ 'Shader Precision': self.config.get('rpcs3_shader', 'Auto'),
+ # Internal resolution (CHANGE AT YOUR OWN RISK)
+ 'Resolution': '1280x720',
+ # Resolution scaling
+ 'Resolution Scale': self.config.get_int('rpcs3_resolution_scale', 100),
+ # Resolution scale threshold
+ 'Minimum Scalable Dimension': int(self.config.get_float('rpcs3_resolution_scale_threshold', 16)),
+ # Output Scaling
+ 'Output Scaling Mode': self.config.get('rpcs3_scaling', 'Bilinear'),
+ # CAS Sharpening
+ 'FidelityFX CAS Sharpening Intensity': self.config.get_int('rpcs3_fsr_sharpening', 50),
+ # Number of Shader Compilers
+ 'Shader Compiler Threads': self.config.get_int('rpcs3_num_compilers', 0),
+ # Multithreaded RSX
+ 'Multithreaded RSX': self.config.get_bool('rpcs3_rsx'),
+ # Write Depth Buffer
+ 'Write Depth Buffer': self.config.get_bool('rpcs3_write_depth_buffers'),
+ # Read Depth Buffer
+ 'Read Depth Buffer': self.config.get_bool('rpcs3_read_depth_buffers'),
+ # Strict Rendering Mode
+ 'Strict Rendering Mode': self.config.get_bool('rpcs3_strict'),
+ # Force CPU blit emulation
+ 'Force CPU Blit': self.config.get_bool('rpcs3_force_cpu_blit_emulation'),
+ },
+ 'Audio': {
+ # defaults
+ 'Renderer': 'Cubeb',
+ 'Master Volume': 100,
+ # audio format
+ 'Audio Format': self.config.get('rpcs3_audio_format', 'Automatic'),
+ # convert to 16 bit
+ 'Convert to 16 bit': self.config.get_bool('rpcs3_audio_16bit'),
+ # audio buffering
+ 'Enable Buffering': time_stretch or self.config.get_bool('rpcs3_audiobuffer', True),
+ # audio buffer duration
+ 'Desired Audio Buffer Duration': self.config.get_int('rpcs3_audiobuffer_duration', 34),
+ # time stretching
+ 'Enable Time Stretching': time_stretch,
+ 'Time Stretching Threshold': self.config.get_int('rpcs3_timestretch_threshold', 75),
+ },
+ 'Input/Output': {
+ 'Pad handler mode': self.config.get('rpcs3_pad_mode', 'Single-threaded'),
+ **(
+ {
+ 'Move': 'Gun',
+ 'Camera': 'Fake',
+ 'Camera type': 'PS Eye',
+ }
+ if self.config.use_guns and self.guns
+ else {}
+ ),
+ 'Show move cursor': self.config.get_bool('rpcs3_crosshairs'),
+ },
+ 'Miscellaneous': {
+ 'Exit RPCS3 when process finishes': True,
+ 'Start games in fullscreen mode': True,
+ 'Show shader compilation hint': False,
+ 'Prevent display sleep while running games': True,
+ 'Show trophy popups': False,
+ },
+ }
+
+ if backend == 'Vulkan':
+ assert vulkan_info is not None
+
+ vulkan_config = config['Video']['Vulkan'] = {}
+
+ if discrete_gpu := vulkan_info.active_discrete_gpu:
+ _logger.debug('A discrete GPU is available on the system. We will use that for performance')
+ discrete_name = discrete_gpu.name
+ if discrete_name:
+ _logger.debug('Using Discrete GPU Name: %s for RPCS3', discrete_name)
+ vulkan_config['Adapter'] = discrete_name
+ else:
+ _logger.debug("Couldn't get discrete GPU Name")
+ else:
+ _logger.debug('Discrete GPU is not available on the system. Using default.')
+
+ vulkan_config['Asynchronous Texture Streaming'] = self.config.get_bool('rpcs3_async_texture')
+ # Asynchronous Queue Scheduler
+ if self.config.get('rpcs3_vk_async_scheduler'):
+ vulkan_config['Asynchronous Queue Scheduler'] = self.config.get('rpcs3_vk_async_scheduler')
+
+ merge(rpcs3_yml_config, config)
+
+ if self.arcade_game_title:
+ rpcs3_yml_config['System']['Console PSID'] = 0
+ else:
+ rpcs3_yml_config['System'].pop('Console PSID', None)
+
+ # Clear leftover gun settings from a previous run when no gun is present
+ if not (self.config.use_guns and self.guns) and rpcs3_yml_config.get('Input/Output', {}).get('Move') == 'Gun':
+ rpcs3_yml_config['Input/Output']['Move'] = 'Null'
+ rpcs3_yml_config['Input/Output']['Camera'] = 'Null'
+ rpcs3_yml_config['Input/Output']['Camera type'] = 'Unknown'
+
+ rpcs3_yml_config['Video'].pop('VSync', None)
+
+ if 'Vulkan' in rpcs3_yml_config['Video']:
+ rpcs3_yml_config['Video']['Vulkan'].pop('Asynchronous Texture Streaming 2', None)
+
+ safe_dump_yaml12(rpcs3_yml_config, config_file_path)
+
+ if self.config.use_guns and self.guns:
+ # D-Pad mapping is face buttons of the PS Move △ =up ✕ =down □ =left ○ =right
+ gun_mapping = {
+ 'T': 1,
+ 'Move': 2,
+ 'Start': 3,
+ 'Select': 4,
+ 'Triangle': 8,
+ 'Cross': 9,
+ 'Square': 10,
+ 'Circle': 11,
+ }
+ safe_dump_yaml12(
+ {
+ f'Player {player}': {key: f'Gun Button {value}' for key, value in gun_mapping.items()}
+ for player in range(1, 5)
+ },
+ self.config_dir / 'gem_gun.yml',
+ )
+
+ self._generate_vfs_config()
+ self._generate_patch_config()
+
+ # copy icon files to config
+ icon_target = self.config_dir / 'Icons'
+ icon_target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree('/usr/share/rpcs3/Icons/', icon_target, dirs_exist_ok=True, copy_function=shutil.copy2)
+
+ # determine the rom name
+ rom_name: Path | None = None
+
+ if self.rom.suffix == '.psn':
+ with self.rom.open() as fp:
+ for line in fp:
+ if len(line) >= 9:
+ rom_name = self.hdd0_dir / 'game' / line.strip().upper() / 'USRDIR' / 'EBOOT.BIN'
+
+ if rom_name is None:
+ raise BatoceraException(f'No game ID found in {self.rom}')
+
+ elif self.is_psn_squashfs:
+ # rom is /var/run/overlays/; dev_hdd0 is redirected there via vfs.yml.
+ # Scan for the game ID directory and pass EBOOT.BIN directly to RPCS3.
+ for game_id_dir in self.rom_game_dir.iterdir():
+ eboot = game_id_dir / 'USRDIR' / 'EBOOT.BIN'
+ if eboot.exists():
+ rom_name = eboot
+ break
+
+ if rom_name is None:
+ raise BatoceraException(f'No PSN game found in squashfs {self.rom}')
+
+ elif self.rom.suffix.lower() == '.iso':
+ rom_name = self.rom
+ elif not configure_emulator(self.rom):
+ rom_name = self.rom / 'PS3_GAME' / 'USRDIR' / 'EBOOT.BIN'
+
+ args: list[Path | str] = [_BIN_PATH, rom_name] if rom_name else [_BIN_PATH]
+
+ if not self.config.get_bool('rpcs3_gui') and rom_name:
+ args.append('--no-gui')
+
+ # firmware not installed and available : instead of starting the game, install it
+ if _get_firmware_version(self.config_dir) is None and (BIOS / 'PS3UPDAT.PUP').exists():
+ args = [_BIN_PATH, '--installfw', BIOS / 'PS3UPDAT.PUP']
+
+ return RPCS3Command(
+ args,
+ {
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_CACHE_HOME': CACHE,
+ 'LC_ALL': 'C',
+ },
+ # Wait for the compatibility database update to finish (or fail) before running the command
+ self.compatibility_database_task,
+ )
diff --git a/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/py.typed b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/py.typed
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/sfo.py b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/sfo.py
new file mode 100644
index 00000000000..f97250236e5
--- /dev/null
+++ b/python-src/batocera-launch-rpcs3/batocera_launch_rpcs3/sfo.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import logging
+import struct
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Literal, Self, overload
+
+if TYPE_CHECKING:
+ from _typeshed import StrPath
+
+_logger = logging.getLogger(__name__)
+
+
+@dataclass(slots=True, frozen=True)
+class SFO:
+ data: dict[str, Any]
+
+ @overload
+ def __getattr__(self, name: Literal['TITLE']) -> str | None: ...
+ @overload
+ def __getattr__(self, name: Literal['TITLE_ID']) -> str | None: ...
+ @overload
+ def __getattr__(self, name: str) -> str | int | None: ...
+ def __getattr__(self, name: str) -> str | int | None:
+ return self.data.get(name, None)
+
+ @classmethod
+ def from_path(cls, path: StrPath) -> Self:
+ path = Path(path)
+
+ if not path.is_file():
+ raise FileNotFoundError(f'File not found: {path}')
+
+ sfo_data = path.read_bytes()
+ data: dict[str, Any] = {}
+
+ try:
+ if len(sfo_data) < 20 or sfo_data[:4] != b'\0PSF':
+ _logger.debug('Invalid PARAM.SFO file: %s', path)
+ else:
+ key_table_start, data_table_start, entries = struct.unpack_from(' Self:
+ for param_sfo_dir in (rom / 'dev_hdd0' / 'game' / 'SCEEXE000', rom / 'PS3_GAME', rom):
+ param_sfo = param_sfo_dir / 'PARAM.SFO'
+
+ if param_sfo.is_file():
+ return cls.from_path(param_sfo)
+
+ return cls({})
diff --git a/python-src/batocera-launch-rpcs3/pyproject.toml b/python-src/batocera-launch-rpcs3/pyproject.toml
new file mode 100644
index 00000000000..a8b6eac3372
--- /dev/null
+++ b/python-src/batocera-launch-rpcs3/pyproject.toml
@@ -0,0 +1,18 @@
+[project]
+name = "batocera-launch-rpcs3"
+version = '44.0'
+dependencies = [
+ 'aiohttp',
+ 'batocera-common',
+ 'batocera-launch',
+]
+
+[project.entry-points."batocera_launch.emulators"]
+rpcs3 = 'batocera_launch_rpcs3:RPCS3'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch_rpcs3"]
diff --git a/python-src/batocera-launch/Config.in b/python-src/batocera-launch/Config.in
new file mode 100644
index 00000000000..017e51a45c8
--- /dev/null
+++ b/python-src/batocera-launch/Config.in
@@ -0,0 +1,16 @@
+config BR2_PACKAGE_BATOCERA_LAUNCH
+ bool
+ select BR2_PACKAGE_PYTHON3
+ select BR2_PACKAGE_PYTHON_PYYAML
+ select BR2_PACKAGE_PYTHON_RUAMEL_YAML
+ select BR2_PACKAGE_PYTHON_TYPING_EXTENSIONS
+ select BR2_PACKAGE_PYTHON_BATOCERA_COMMON
+ select BR2_PACKAGE_PYTHON_UVLOOP
+ select BR2_PACKAGE_PYTHON_EVDEV
+ select BR2_PACKAGE_PYTHON_PYUDEV # for guns
+ select BR2_PACKAGE_PYTHON_TOML
+ select BR2_PACKAGE_PYTHON_PILLOW
+ select BR2_PACKAGE_PYTHON_QRCODE # for retroachievement urls
+ select BR2_PACKAGE_MANGOHUD if (BR2_PACKAGE_WAYLAND || BR2_PACKAGE_XORG7 || BR2_PACKAGE_LIBDRM) && !BR2_PACKAGE_BATOCERA_TARGET_S905
+ help
+ The emulator launcher for batocera.
diff --git a/python-src/batocera-launch/batocera-launch.mk b/python-src/batocera-launch/batocera-launch.mk
new file mode 100644
index 00000000000..10f85bbc558
--- /dev/null
+++ b/python-src/batocera-launch/batocera-launch.mk
@@ -0,0 +1,143 @@
+################################################################################
+#
+# batocera-launch
+#
+################################################################################
+
+BATOCERA_LAUNCH_SETUP_TYPE=hatch
+BATOCERA_LAUNCH_DEPENDENCIES = \
+ python-batocera-common \
+ python-uvloop \
+ python-evdev \
+ python-pyudev \
+ python-toml \
+ python-pillow \
+ python-qrcode
+BATOCERA_LAUNCH_INSTALL_STAGING = YES
+
+BATOCERA_LAUNCH_LOCAL_PYTHON_EXCLUSIONS = \
+ $(addprefix batocera_launch/emulators/, \
+ $(if $(BR2_PACKAGE_ABUSE),,abuse.py) \
+ $(if $(BR2_PACKAGE_APPLEWIN),,applewin.py) \
+ $(if $(BR2_PACKAGE_AZAHAR),,azahar.py) \
+ $(if $(BR2_PACKAGE_BSTONE),,bstone.py) \
+ $(if $(BR2_PACKAGE_CANNONBALL),,cannonball.py) \
+ $(if $(BR2_PACKAGE_CATACOMBGL),,catacombgl.py) \
+ $(if $(BR2_PACKAGE_CLK),,clk.py) \
+ $(if $(BR2_PACKAGE_CORSIXTH),,corsixth.py) \
+ $(if $(BR2_PACKAGE_DEVILUTIONX),,devilutionx.py) \
+ $(if $(BR2_PACKAGE_DHEWM3),,dhewm3.py) \
+ $(if $(BR2_PACKAGE_DOSBOX),,dosbox.py) \
+ $(if $(BR2_PACKAGE_DOSBOX_STAGING),,dosbox_staging.py) \
+ $(if $(BR2_PACKAGE_DOSBOX_X),,dosboxx.py) \
+ $(if $(BR2_PACKAGE_DXX_REBIRTH),,dxx_rebirth.py) \
+ $(if $(BR2_PACKAGE_EASYRPG_PLAYER),,easyrpg.py) \
+ $(if $(BR2_PACKAGE_ECWOLF),,ecwolf.py) \
+ $(if $(BR2_PACKAGE_EDUKE32),,eduke32.py) \
+ $(if $(BR2_PACKAGE_ETLEGACY),,etlegacy.py) \
+ $(if $(BR2_PACKAGE_PIFBA),,fba2x.py) \
+ $(if $(BR2_PACKAGE_FLATPAK),,flatpak.py) \
+ $(if $(BR2_PACKAGE_GSPLUS),,gsplus.py) \
+ $(if $(BR2_PACKAGE_HATARI),,hatari.py) \
+ $(if $(BR2_PACKAGE_HCL),,hcl.py) \
+ $(if $(BR2_PACKAGE_HURRICAN),,hurrican.py) \
+ $(if $(BR2_PACKAGE_IKEMEN),,ikemen.py) \
+ $(if $(BR2_PACKAGE_IOQUAKE3)$(BR2_PACKAGE_VKQUAKE3),,ioquake3.py) \
+ $(if $(BR2_PACKAGE_IORTCW),,iortcw.py) \
+ $(if $(BR2_PACKAGE_JAZZ2_NATIVE),,jazz2_native.py) \
+ $(if $(BR2_PACKAGE_LIGHTSPARK),,lightspark.py) \
+ $(if $(BR2_PACKAGE_NANOBOYADVANCE),,nanoboyadvance.py) \
+ $(if $(BR2_PACKAGE_OD_COMMANDER),,odcommander.py) \
+ $(if $(BR2_PACKAGE_BATOCERA_PYGAME),,pygame.py) \
+ $(if $(BR2_PACKAGE_PYTHON_PYXEL),,pyxel.py) \
+ $(if $(BR2_PACKAGE_RAZE),,raze.py) \
+ $(if $(BR2_PACKAGE_REDREAM),,redream.py) \
+ $(if $(BR2_PACKAGE_RUFFLE),,ruffle.py) \
+ $(if $(BR2_PACKAGE_SIMCOUPE),,samcoupe.py) \
+ $(if $(BR2_PACKAGE_SCUMMVM),,scummvm.py) \
+ $(if $(BR2_PACKAGE_SDLPOP),,sdlpop.py) \
+ $(if $(BR2_PACKAGE_SOLARUS_ENGINE),,solarus.py) \
+ $(if $(BR2_PACKAGE_SONIC3_AIR),,sonic3_air.py) \
+ $(if $(BR2_PACKAGE_SONIC_MANIA),,sonic_mania.py) \
+ $(if $(BR2_PACKAGE_SONIC2013)$(BR2_PACKAGE_SONICCD),,sonicretro.py) \
+ $(if $(BR2_PACKAGE_BATOCERA_STEAM),,steam.py) \
+ $(if $(BR2_PACKAGE_STELLA),,stella.py) \
+ $(if $(BR2_PACKAGE_TARADINO),,taradino.py) \
+ $(if $(BR2_PACKAGE_THEFORCEENGINE),,theforceengine.py) \
+ $(if $(BR2_PACKAGE_THEXTECH),,thextech.py) \
+ $(if $(BR2_PACKAGE_TIC80),,tic80.py) \
+ $(if $(BR2_PACKAGE_TRX),,trx.py) \
+ $(if $(BR2_PACKAGE_TSUGARU),,tsugaru.py) \
+ $(if $(BR2_PACKAGE_TYRIAN),,tyrian.py) \
+ $(if $(BR2_PACKAGE_UQM),,uqm.py) \
+ $(if $(BR2_PACKAGE_VKQUAKE),,vkquake.py) \
+ $(if $(BR2_PACKAGE_VKQUAKE2),,vkquake2.py) \
+ $(if $(BR2_PACKAGE_X16EMU),,x16emu.py) \
+ $(if $(BR2_PACKAGE_XASH3D_FWGS),,xash3d_fwgs/) \
+ $(if $(BR2_PACKAGE_XROAR),,xroar.py) \
+ $(if $(BR2_PACKAGE_YQUAKE2),,yquake2.py))
+
+ifeq ($(BR2_PACKAGE_BATOCERA_TARGET_X86_64_ANY),y)
+BATOCERA_LAUNCH_ARCH = x86_64
+else
+BATOCERA_LAUNCH_ARCH = $(BATOCERA_ARCH)
+endif
+
+define BATOCERA_LAUNCH_INSTALL_TARGET_DEFAULT_OPTIONS
+ mkdir -p $(TARGET_DIR)/usr/share/batocera/launch/defaults
+ $(INSTALL) -D -m 0644 $(@D)/resources/defaults/config.yml \
+ $(TARGET_DIR)/usr/share/batocera/launch/defaults/config.yml
+
+ if test -e $(@D)/resources/defaults/config-$(BATOCERA_LAUNCH_ARCH).yml; then \
+ $(INSTALL) -D -m 0644 $(@D)/resources/defaults/config-$(BATOCERA_LAUNCH_ARCH).yml \
+ $(TARGET_DIR)/usr/share/batocera/launch/defaults/config-arch.yml; \
+ fi
+endef
+
+define BATOCERA_LAUNCH_INSTALL_STAGING_DEFAULT_OPTIONS
+ mkdir -p $(STAGING_DIR)/usr/share/batocera/launch
+ $(INSTALL) -D -m 0644 $(@D)/resources/defaults/config.yml \
+ $(STAGING_DIR)/usr/share/batocera/launch/defaults/config.yml
+
+ if test -e $(@D)/resources/defaults/config-$(BATOCERA_LAUNCH_ARCH).yml; then \
+ $(INSTALL) -D -m 0644 $(@D)/resources/defaults/config-$(BATOCERA_LAUNCH_ARCH).yml \
+ $(STAGING_DIR)/usr/share/batocera/launch/defaults/config-arch.yml; \
+ fi
+endef
+
+define BATOCERA_LAUNCH_INSTALL_RESOURCES
+ mkdir -p $(TARGET_DIR)/usr/share/batocera/launch/scripts
+ mkdir -p $(TARGET_DIR)/usr/share/evmapy
+
+ $(INSTALL) -D -m 0644 -t $(TARGET_DIR)/usr/share/batocera/launch/data \
+ $(@D)/resources/data/gamesbuttonsdb.xml
+
+ $(INSTALL) -D -m 0644 -t $(TARGET_DIR)/usr/share/batocera/launch/data/special \
+ $(@D)/resources/data/special/*.toml
+
+ $(INSTALL) -m 0755 -t $(TARGET_DIR)/usr/share/batocera/launch/scripts \
+ $(@D)/resources/scripts/powermode_launch_hooks.sh
+
+ # evmapy default hotkeys file
+ $(INSTALL) -D -m 0644 -t $(TARGET_DIR)/usr/share/evmapy \
+ $(@D)/resources/hotkeys.keys
+endef
+
+define BATOCERA_LAUNCH_INSTALL_X86_64_SCRIPTS
+ mkdir -p $(TARGET_DIR)/usr/share/batocera/launch/scripts
+
+ $(INSTALL) -m 0755 -t $(TARGET_DIR)/usr/share/batocera/launch/scripts \
+ $(@D)/resources/scripts/tdp_hooks.sh \
+ $(@D)/resources/scripts/nvidia-workaround.sh
+endef
+
+BATOCERA_LAUNCH_POST_INSTALL_TARGET_HOOKS += BATOCERA_LAUNCH_INSTALL_TARGET_DEFAULT_OPTIONS
+BATOCERA_LAUNCH_POST_INSTALL_TARGET_HOOKS += BATOCERA_LAUNCH_INSTALL_RESOURCES
+
+ifeq ($(BR2_PACKAGE_BATOCERA_TARGET_X86_64_ANY),y)
+BATOCERA_LAUNCH_POST_INSTALL_TARGET_HOOKS += BATOCERA_LAUNCH_INSTALL_X86_64_SCRIPTS
+endif
+
+BATOCERA_LAUNCH_POST_INSTALL_STAGING_HOOKS += BATOCERA_LAUNCH_INSTALL_STAGING_DEFAULT_OPTIONS
+
+$(eval $(local-python-package))
diff --git a/python-src/batocera-launch/batocera_launch/__init__.py b/python-src/batocera-launch/batocera_launch/__init__.py
new file mode 100644
index 00000000000..475c2041ffa
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/__init__.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from .command import Command as Command
+from .config.build_engine import parse_build_engine_args as parse_build_engine_args
+from .config.config import (
+ Config as Config,
+ SystemConfig as SystemConfig,
+ UIMode as UIMode,
+)
+from .config.decoration_id import get_decoration_id as get_decoration_id
+from .devices.controller import (
+ Controller as Controller,
+ ControllerList as ControllerList,
+ Controllers as Controllers,
+)
+from .devices.device import (
+ DeviceInfo as DeviceInfo,
+ DeviceInfoDict as DeviceInfoDict,
+ DeviceInfoMapping as DeviceInfoMapping,
+ get_associated_mouse as get_associated_mouse,
+ get_device_info as get_device_info,
+)
+from .devices.gun import (
+ Gun as Gun,
+ GunList as GunList,
+ Guns as Guns,
+ guns_need_crosses as guns_need_crosses,
+)
+from .devices.input import Input as Input, InputDict as InputDict, InputMapping as InputMapping
+from .emulator import Emulator as Emulator, SpecialDecorationsMixin as SpecialDecorationsMixin
+from .exceptions import (
+ BadCommandLineArguments as BadCommandLineArguments,
+ BatoceraException as BatoceraException,
+ InvalidConfiguration as InvalidConfiguration,
+ MissingCore as MissingCore,
+ MissingEmulator as MissingEmulator,
+ UnexpectedEmulatorExit as UnexpectedEmulatorExit,
+ UnknownEmulator as UnknownEmulator,
+)
+from .rom import Rom as Rom
+from .types import (
+ BezelFiles as BezelFiles,
+ BezelInfo as BezelInfo,
+ HotkeysContext as HotkeysContext,
+ Resolution as Resolution,
+)
diff --git a/python-src/batocera-launch/batocera_launch/__main__.py b/python-src/batocera-launch/batocera_launch/__main__.py
new file mode 100644
index 00000000000..004979859e9
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/__main__.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from .cli.main import main
+
+main()
diff --git a/python-src/batocera-launch/batocera_launch/asyncio.py b/python-src/batocera-launch/batocera_launch/asyncio.py
new file mode 100644
index 00000000000..d03ef68fb84
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/asyncio.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import logging
+import os
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.asyncio import run
+
+from .paths import SYSTEM_SCRIPTS, USER_SCRIPTS
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator, Iterable
+ from pathlib import Path
+
+_logger: Final = logging.getLogger(__name__)
+
+
+async def call_script(directory: Path, event: str, args: Iterable[str | Path], /) -> None:
+ if not directory.is_dir():
+ return
+
+ for file in directory.iterdir():
+ if file.is_dir():
+ await call_script(file, event, args)
+ elif os.access(file, os.X_OK):
+ _logger.debug('calling script: %s', [file, event, *args])
+ await run(file, event, *args)
+
+
+@asynccontextmanager
+async def script_caller(events: tuple[Iterable[str], Iterable[str]], /, *args: str | Path) -> AsyncGenerator[None]:
+ before_events, after_events = events
+
+ if isinstance(before_events, str):
+ before_events = [before_events]
+
+ if isinstance(after_events, str):
+ after_events = [after_events]
+
+ for event in before_events:
+ for directory in (SYSTEM_SCRIPTS, USER_SCRIPTS):
+ await call_script(directory, event, args)
+
+ try:
+ yield
+ finally:
+ for event in after_events:
+ for directory in (USER_SCRIPTS, SYSTEM_SCRIPTS):
+ await call_script(directory, event, args)
diff --git a/python-src/batocera-launch/batocera_launch/cli/__init__.py b/python-src/batocera-launch/batocera_launch/cli/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/python-src/batocera-launch/batocera_launch/cli/arguments.py b/python-src/batocera-launch/batocera_launch/cli/arguments.py
new file mode 100644
index 00000000000..0ea471f5aab
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/cli/arguments.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from argparse import ArgumentParser, Namespace
+from dataclasses import dataclass, fields
+from pathlib import Path
+from typing import Any, Self
+
+
+@dataclass(slots=True, frozen=True)
+class PlayerArguments:
+ index: int
+ guid: str
+ name: str
+ devicepath: str
+ nbbuttons: int
+ nbhats: int
+ nbaxes: int
+
+
+@dataclass(slots=True, frozen=True)
+class Arguments:
+ system: str
+ rom: Path
+ emulator: str | None
+ core: str | None
+ players: tuple[PlayerArguments | None, ...]
+ netplaymode: str | None
+ netplaypass: str | None
+ netplayip: str | None
+ netplayport: str | None
+ netplaysession: str | None
+ state_slot: str | None
+ state_filename: str | None
+ autosave: str | None
+ systemname: str | None
+ gameinfoxml: Path
+ lightgun: bool
+ wheel: bool
+ trackball: bool
+ spinner: bool
+
+ @classmethod
+ def from_namespace(cls, args: Namespace, max_players: int) -> Self:
+ arguments_dict: dict[str, Any] = {}
+
+ for field in fields(cls):
+ if field.name == 'players':
+ arguments_dict[field.name] = tuple(
+ None
+ if (index := getattr(args, f'p{p}index')) is None
+ or (guid := getattr(args, f'p{p}guid')) is None
+ or (name := getattr(args, f'p{p}name')) is None
+ or (devicepath := getattr(args, f'p{p}devicepath')) is None
+ or (nbbuttons := getattr(args, f'p{p}nbbuttons')) is None
+ or (nbhats := getattr(args, f'p{p}nbhats')) is None
+ or (nbaxes := getattr(args, f'p{p}nbaxes')) is None
+ else PlayerArguments(
+ index=index,
+ guid=guid,
+ name=name,
+ devicepath=devicepath,
+ nbbuttons=nbbuttons,
+ nbhats=nbhats,
+ nbaxes=nbaxes,
+ )
+ for p in range(1, max_players + 1)
+ )
+ else:
+ arguments_dict[field.name] = getattr(args, field.name)
+
+ return cls(**arguments_dict)
+
+ @classmethod
+ def parse(cls, max_players: int, /) -> Self:
+ parser = ArgumentParser()
+
+ for p in range(1, max_players + 1):
+ parser.add_argument(f'-p{p}index', help=f'player{p} controller index', type=int, required=False)
+ parser.add_argument(f'-p{p}guid', help=f'player{p} controller SDL2 guid', type=str, required=False)
+ parser.add_argument(f'-p{p}name', help=f'player{p} controller name', type=str, required=False)
+ parser.add_argument(f'-p{p}devicepath', help=f'player{p} controller device', type=str, required=False)
+ parser.add_argument(
+ f'-p{p}nbbuttons', help=f'player{p} controller number of buttons', type=int, required=False
+ )
+ parser.add_argument(f'-p{p}nbhats', help=f'player{p} controller number of hats', type=int, required=False)
+ parser.add_argument(f'-p{p}nbaxes', help=f'player{p} controller number of axes', type=int, required=False)
+
+ parser.add_argument('-system', help='select the system to launch', type=str, required=True)
+ parser.add_argument('-rom', help='rom absolute path', type=Path, required=True)
+ parser.add_argument('-emulator', help='force emulator', type=str, required=False)
+ parser.add_argument('-core', help='force emulator core', type=str, required=False)
+ parser.add_argument('-netplaymode', help='host/client', type=str, required=False)
+ parser.add_argument('-netplaypass', help='enable spectator mode', type=str, required=False)
+ parser.add_argument('-netplayip', help='remote ip', type=str, required=False)
+ parser.add_argument('-netplayport', help='remote port', type=str, required=False)
+ parser.add_argument('-netplaysession', help='netplay session', type=str, required=False)
+ parser.add_argument('-state_slot', help='state slot', type=str, required=False)
+ parser.add_argument('-state_filename', help='state filename', type=str, required=False)
+ parser.add_argument('-autosave', help='autosave', type=str, required=False)
+ parser.add_argument('-systemname', help='system fancy name', type=str, required=False)
+ parser.add_argument(
+ '-gameinfoxml', help='game info xml', type=Path, nargs='?', default=Path('/dev/null'), required=False
+ )
+ parser.add_argument('-lightgun', help='configure lightguns', action='store_true')
+ parser.add_argument('-wheel', help='configure wheel', action='store_true')
+ parser.add_argument('-trackball', help='configure trackball', action='store_true')
+ parser.add_argument('-spinner', help='configure spinner', action='store_true')
+
+ args = parser.parse_args()
+
+ return cls.from_namespace(args, max_players)
diff --git a/python-src/batocera-launch/batocera_launch/cli/main.py b/python-src/batocera-launch/batocera_launch/cli/main.py
new file mode 100644
index 00000000000..1c18b24cc07
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/cli/main.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from typing import Final
+
+from ..logging import setup_logging
+from ..profiler import Profiler
+from .arguments import Arguments
+
+_MAX_PLAYERS: Final = 8
+
+
+def main() -> None:
+ arguments = Arguments.parse(_MAX_PLAYERS)
+
+ with setup_logging(), Profiler('/var/run/batocera-launch.perf') as profiler:
+ from ..launch import launch
+
+ launch(arguments, profiler)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/python-src/batocera-launch/batocera_launch/command.py b/python-src/batocera-launch/batocera_launch/command.py
new file mode 100644
index 00000000000..733a817b69c
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/command.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import signal
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Final, cast
+
+from batocera_common.asyncio import env_to_fspath
+
+from .exceptions import BadCommandLineArguments, UnexpectedEmulatorExit
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, MutableMapping, MutableSequence
+ from pathlib import Path
+
+_logger: Final = logging.getLogger(__name__)
+_emulator_logger: Final = logging.getLogger('emulator')
+
+
+async def _log_emulator_output(stream: asyncio.StreamReader, level: int, prefix: str) -> None:
+ while line := await stream.readline():
+ _emulator_logger.log(level, '%s %s', prefix, line.decode(errors='backslashreplace').rstrip())
+
+
+@dataclass(slots=True)
+class Command:
+ args: MutableSequence[str | Path]
+ env: MutableMapping[str, str | Path] = field(default_factory=cast('type[MutableMapping[str, str | Path]]', dict))
+
+ # Can be used to wait for something to complete (like background task for downloading a file) before running
+ # the command
+ wait_for: Awaitable[object] | None = None
+
+ def __post_init__(self) -> None:
+ self.args = list(self.args)
+ self.env = dict(self.env)
+
+ def update_env(self, **kwargs: str | Path) -> None:
+ self.env.update(kwargs)
+
+ def prepend_args(self, *args: str | Path) -> None:
+ self.args = [*args, *self.args]
+
+ async def run(self) -> int:
+ env: dict[str, str | Path] = os.environ | self.env
+
+ _logger.debug('Running command: %s', ' '.join(map(str, self.args)))
+ _logger.debug('Environment variables: %s', self.env)
+
+ if not self.args:
+ raise BadCommandLineArguments
+
+ if self.wait_for is not None:
+ await self.wait_for
+
+ proc = await asyncio.create_subprocess_exec(
+ 'nice',
+ '-n',
+ '-4',
+ *self.args,
+ env=env_to_fspath(env),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+
+ def _signal_handler() -> None:
+ if proc.returncode is None:
+ _logger.debug('Killing process')
+ proc.kill()
+
+ loop = asyncio.get_running_loop()
+ loop.add_signal_handler(signal.SIGINT, _signal_handler)
+
+ assert proc.stdout is not None
+ assert proc.stderr is not None
+
+ # Set up async tasks to read from stdout and stderr and log the output while the
+ # process is running. The tasks continuously read from the streams and log the output
+ # until the process terminates or an exception occurs. Since this is done in tasks,
+ # this allows the main coroutine to wait for the process to finish while still logging
+ # output in real-time.
+ stdout_task = asyncio.create_task(_log_emulator_output(proc.stdout, logging.DEBUG, '[stdout]'))
+ stderr_task = asyncio.create_task(_log_emulator_output(proc.stderr, logging.ERROR, '[stderr]'))
+
+ reader_tasks = (stdout_task, stderr_task)
+ exit_code = 0
+
+ async def _cleanup_process() -> None:
+ for task in reader_tasks:
+ task.cancel()
+
+ await asyncio.gather(*reader_tasks, return_exceptions=True)
+
+ if proc.returncode is None:
+ proc.kill()
+
+ # Reap the process and drain any remaining buffered pipe data.
+ await proc.communicate()
+
+ try:
+ await asyncio.gather(*reader_tasks)
+ exit_code = await proc.wait()
+ except BrokenPipeError:
+ # Seeing BrokenPipeError? This is probably caused by head truncating output in the front-end
+ # Examine es-core/src/platform.cpp::runSystemCommand for additional context
+ await _cleanup_process()
+ except BaseException as e:
+ _logger.error('emulator exited')
+
+ await _cleanup_process()
+
+ raise UnexpectedEmulatorExit from e
+ finally:
+ loop.remove_signal_handler(signal.SIGINT)
+
+ return exit_code
diff --git a/python-src/batocera-launch/batocera_launch/config/__init__.py b/python-src/batocera-launch/batocera_launch/config/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/python-src/batocera-launch/batocera_launch/config/build_engine.py b/python-src/batocera-launch/batocera_launch/config/build_engine.py
new file mode 100644
index 00000000000..fe190ae0180
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/config/build_engine.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Final
+
+from ..exceptions import InvalidConfiguration
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+ from pathlib import Path
+
+
+@dataclass(frozen=True, slots=True)
+class _BuildEngineArg:
+ key: str
+ option: str
+ only_one: bool = False
+
+
+# Shared by EDuke32 and Raze (with noted differences).
+_BUILD_ENGINE_ARGS: Final = {
+ arg.key: arg
+ for arg in (
+ _BuildEngineArg('DIR', '-j'), # Adds directory to search list
+ # Main game file: EDuke32/Raze load .grp/.zip/.ssi/.pk3/.pk4; Raze also .7z
+ _BuildEngineArg('FILE', '-gamegrp', only_one=True),
+ _BuildEngineArg('FILE+', '-g'), # Extra game file; overrides virtual FS entries
+ # Replace main GAME.CON script module (may be CON, DEF, or INI)
+ _BuildEngineArg('CON', '-x', only_one=True),
+ _BuildEngineArg('CON+', '-mx'), # Append CON after GAME.CON
+ _BuildEngineArg('DEF', '-h', only_one=True), # Replace main DEF module
+ _BuildEngineArg('DEF+', '-mh'), # Append DEF after main DEF module
+ _BuildEngineArg('MAP', '-map', only_one=True), # Start specified MAP on launch
+ )
+}
+
+
+def parse_build_engine_args(
+ rom_path: Path,
+ /,
+ *,
+ existing: Iterable[str | Path] | None = None,
+) -> list[str | Path]:
+ """Parse Build Engine launcher arguments from a ROM script (EDuke32 / Raze).
+
+ Lines are ``KEY=value``. A leading ``/`` on the value means a path relative to the
+ ROM's parent directory (physical filesystem); otherwise the value is kept as a
+ virtual-filesystem path.
+
+ Args:
+ rom_path: Path to the launcher script.
+ existing: CLI args already present; used to enforce single-use options.
+
+ Returns:
+ Alternating option/value pairs suitable for extending a command line.
+
+ Raises:
+ InvalidConfiguration: If the script contains one or more parse errors.
+ """
+ lines = rom_path.read_text().splitlines()
+ args: list[str | Path] = []
+ seen_options: set[str] = set() if existing is None else {arg for arg in existing if isinstance(arg, str)}
+ errors: list[tuple[int, str]] = []
+
+ for line_no, raw_line in enumerate(lines):
+ line = raw_line.strip()
+ if not line or line.startswith(('#', '//')):
+ continue
+
+ parts = line.split('=')
+ if len(parts) > 2:
+ errors.append((line_no, "found another '=', but there should only be one"))
+ continue
+ if len(parts) < 2:
+ errors.append((line_no, "KEY and/or VAL is empty; are you missing a '='?"))
+ continue
+
+ key = parts[0].strip().upper()
+ value_str = parts[1].strip()
+ if not key or not value_str:
+ errors.append((line_no, "KEY and/or VAL is empty; are you missing a '='?"))
+ continue
+
+ build_arg = _BUILD_ENGINE_ARGS.get(key)
+ if build_arg is None:
+ errors.append((line_no, f"KEY '{key}' is not valid"))
+ continue
+
+ # "/" denotes a path under the ROM directory; otherwise treat as virtual FS.
+ value: str | Path
+ if value_str.startswith('/'):
+ value = rom_path.parent / value_str[1:]
+ if not value.exists():
+ errors.append((line_no, f'{value} does not exist'))
+ continue
+ else:
+ value = value_str
+
+ if build_arg.only_one and build_arg.option in seen_options:
+ errors.append((line_no, f"found another '{build_arg.key}', but there should only be one"))
+ continue
+
+ args.extend((build_arg.option, value))
+ seen_options.add(build_arg.option)
+
+ if errors:
+ message = f'{len(errors)} error(s) found in {rom_path}:\n'
+ message += ''.join(f'line {line_no + 1}| {lines[line_no]}\t<-- {error}\n' for line_no, error in errors)
+ raise InvalidConfiguration(message)
+
+ return args
diff --git a/python-src/batocera-launch/batocera_launch/config/config.py b/python-src/batocera-launch/batocera_launch/config/config.py
new file mode 100644
index 00000000000..af3a2673cd5
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/config/config.py
@@ -0,0 +1,248 @@
+from __future__ import annotations
+
+import logging
+from collections import ChainMap
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Self, cast, overload
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.key_value_config import KeyValueConfig
+from batocera_common.paths import BATOCERA_CONF
+
+from ..exceptions import MissingEmulator
+from ..paths import BATOCERA_SHADERS, USER_SHADERS
+from .defaults import load_defaults, load_system_defaults
+from .es_settings import ESSettings
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator, KeysView, MutableMapping, ValuesView
+ from pathlib import Path
+
+ from ..cli.arguments import Arguments
+
+_logger: Final = logging.getLogger(__name__)
+
+type UIMode = Literal['Full', 'Kiosk', 'Kid']
+
+
+@dataclass(slots=True, frozen=True)
+class Config:
+ TRUE_VALUES: ClassVar[set[Literal['1', 'true', 'on', 'enabled', True]]] = {'1', 'true', 'on', 'enabled', True}
+
+ data: MutableMapping[str, Any]
+
+ def __len__(self) -> int:
+ return len(self.data)
+
+ def __contains__(self, x: object, /) -> bool:
+ return x in self.data
+
+ def __getitem__(self, key: str, /) -> Any:
+ return self.data[key]
+
+ def __setitem__(self, key: str, value: Any, /) -> None:
+ self.data[key] = value
+
+ def __delitem__(self, key: str) -> None:
+ del self.data[key]
+
+ def __iter__(self) -> Iterator[Any]:
+ return self.data.__iter__()
+
+ @overload
+ def get(self, key: str, /) -> Any | None: ...
+
+ @overload
+ def get[T](self, key: str, /, default: T) -> Any | T: ...
+
+ def get[T](self, key: str, /, default: T | None = None) -> Any | T | None:
+ return self.data.get(key, default)
+
+ @overload
+ def get_bool(self, key: str, /, default: bool = False, *, return_values: None = None) -> bool: ...
+
+ @overload
+ def get_bool[T, F](self, key: str, /, default: bool = False, *, return_values: tuple[T, F]) -> T | F: ...
+
+ def get_bool[T, F](
+ self, key: str, /, default: bool = False, *, return_values: tuple[T, F] | None = None
+ ) -> bool | T | F:
+ value = self.data.get(key)
+
+ if value is None:
+ if return_values is None:
+ return default
+
+ return return_values[not default]
+
+ if isinstance(value, str):
+ value = value.lower()
+
+ if return_values is None:
+ return value in self.TRUE_VALUES
+
+ return return_values[value not in self.TRUE_VALUES]
+
+ def get_str[D: str | None](self, key: str, /, default: D = None) -> str | D:
+ value = self.data.get(key)
+
+ if value is None:
+ return default
+
+ return str(value)
+
+ def get_int[D: int | None](self, key: str, /, default: D = None) -> int | D:
+ value = self.data.get(key)
+
+ if value is None:
+ return default
+
+ return int(value)
+
+ def get_float[D: float | None](self, key: str, /, default: D = None) -> float | D:
+ value = self.data.get(key)
+
+ if value is None:
+ return default
+
+ return float(value)
+
+ def items(self, /, *, starts_with: str | None = None) -> Iterator[tuple[str, Any]]:
+ if starts_with is None:
+ yield from self.data.items()
+ else:
+ starts_with_len = len(starts_with)
+ for key, value in self.data.items():
+ if key.startswith(starts_with):
+ yield key[starts_with_len:], value
+
+ def keys(self) -> KeysView[str]:
+ return self.data.keys()
+
+ def values(self) -> ValuesView[Any]:
+ return self.data.values()
+
+
+@cached_dataclass(frozen=True)
+class SystemConfig(Config):
+ cli_args: Arguments
+ es_settings: ESSettings
+ system: str
+ rom: Path
+ emulator: str
+ emulator_forced: bool
+ raw_core: str | None
+ core: str
+ core_forced: bool
+ use_guns: bool
+ use_wheels: bool
+ ui_mode: Literal['Full', 'Kiosk', 'Kid']
+ show_fps: bool
+ netplay_mode: str | None
+ netplay_password: str | None
+ netplay_server_ip: str | None
+ netplay_server_port: str | None
+ netplay_server_session: str | None
+ state_slot: str | None
+ autosave: str | None
+ state_filename: str | None
+
+ @property
+ def video_mode(self) -> str:
+ return self.get_str('videomode') or 'default'
+
+ @cached_property
+ def render_config(self) -> Config:
+ render_data: dict[str, Any] = {}
+
+ if (shader_set := self.get('shaderset')) is not None:
+ if shader_set == 'none':
+ rendering_defaults = BATOCERA_SHADERS / 'configs' / 'rendering-defaults.yml'
+ else:
+ rendering_defaults = USER_SHADERS / 'configs' / shader_set / 'rendering-defaults.yml'
+ if not rendering_defaults.exists():
+ rendering_defaults = BATOCERA_SHADERS / 'configs' / shader_set / 'rendering-defaults.yml'
+
+ render_data = load_defaults(
+ self.system, rendering_defaults, rendering_defaults.with_name('rendering-defaults-arch.yml')
+ )
+
+ return Config(render_data)
+
+ @classmethod
+ def load(cls, args: Arguments, /) -> Self:
+ # load configuration from batocera.conf
+ user_config = KeyValueConfig()
+ user_config.read(BATOCERA_CONF)
+
+ rom = args.rom
+
+ # sanitize rule by EmulationStation
+ # see FileData::getConfigurationName() on batocera-emulationstation
+ settings_name = rom.name.replace('=', '').replace('#', '')
+
+ user_settings = ChainMap(
+ user_config.section(f'{args.system}["{settings_name}"]'), # game-specific
+ user_config.section(f'{args.system}.folder["{rom.parent}"]'), # folder-specific
+ user_config.section(args.system),
+ user_config.section('global'),
+ )
+
+ # A few emulators have config options named "language", so "system.language" is chosen
+ # in order to prevent conflicts with config options from es_features.yaml
+ language = user_config.get('system.language')
+
+ data = ChainMap[str, Any](
+ {},
+ user_settings,
+ {'system.language': language} if language is not None else {},
+ {f'controllers.{key}': value for key, value in user_config.section_items('controllers')},
+ {f'display.{key}': value for key, value in user_config.section_items('display', keep_defaults=True)},
+ # read the configuration from the batocera-launch defaults files
+ load_system_defaults(args.system),
+ )
+
+ if 'emulator' not in data or not data['emulator']:
+ _logger.error('no emulator defined. exiting.')
+ raise MissingEmulator
+
+ es_settings = ESSettings.load()
+
+ show_fps = es_settings.get_bool('DrawFramerate')
+
+ ui_mode = cast('UIMode', es_settings.get_str('UIMode', 'Full'))
+ if ui_mode not in ('Full', 'Kiosk', 'Kid'):
+ ui_mode = 'Full'
+
+ emulator = data['emulator']
+ if args.emulator is not None:
+ emulator = args.emulator
+
+ core = data.get('core', None)
+ if args.core is not None:
+ core = args.core
+
+ return cls(
+ data,
+ cli_args=args,
+ es_settings=es_settings,
+ system=args.system,
+ rom=rom,
+ emulator=emulator,
+ emulator_forced=('emulator' in user_settings or args.emulator is not None),
+ raw_core=core,
+ core=core or '',
+ core_forced=('core' in user_settings or args.core is not None),
+ use_guns=data.get('use_guns', args.lightgun or False),
+ use_wheels=data.get('use_wheels', args.wheel or False),
+ ui_mode=ui_mode,
+ show_fps=show_fps,
+ netplay_mode=args.netplaymode,
+ netplay_password=args.netplaypass,
+ netplay_server_ip=args.netplayip,
+ netplay_server_port=args.netplayport,
+ netplay_server_session=args.netplaysession,
+ state_slot=args.state_slot,
+ autosave=args.autosave,
+ state_filename=args.state_filename,
+ )
diff --git a/python-src/batocera-launch/batocera_launch/config/decoration_id.py b/python-src/batocera-launch/batocera_launch/config/decoration_id.py
new file mode 100644
index 00000000000..ffe3ce423f6
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/config/decoration_id.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+import tomllib
+
+from ..paths import LAUNCH_DATA_DIR
+
+
+def get_decoration_id(system_name: str, rom_id: str, /) -> str:
+ system_toml = LAUNCH_DATA_DIR / 'special' / f'{system_name}.toml'
+ if not system_toml.is_file():
+ return '0'
+
+ special_dict = tomllib.loads(system_toml.read_text())
+
+ rom_compare = rom_id.casefold()
+ return next((value for key, value in special_dict.items() if rom_compare == key.casefold()), '0')
diff --git a/python-src/batocera-launch/batocera_launch/config/defaults.py b/python-src/batocera-launch/batocera_launch/config/defaults.py
new file mode 100644
index 00000000000..d5255670c58
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/config/defaults.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import yaml
+
+from batocera_common.dict import merge
+
+from ..paths import DEFAULTS_DIR
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+def load_defaults(system_name: str, default_yml: Path, default_arch_yml: Path, /) -> dict[str, Any]:
+ defaults = yaml.load(default_yml.read_text(), Loader=yaml.CLoader)
+
+ arch_defaults: dict[str, Any] = {}
+ if default_arch_yml.exists():
+ loaded_arch_defaults = yaml.load(default_arch_yml.read_text(), Loader=yaml.CLoader)
+ if loaded_arch_defaults is not None:
+ arch_defaults = loaded_arch_defaults
+
+ config: dict[str, Any] = {}
+
+ if 'default' in defaults:
+ config = defaults['default']
+
+ if 'default' in arch_defaults:
+ merge(config, arch_defaults['default'])
+
+ if system_name in defaults:
+ merge(config, defaults[system_name])
+
+ if system_name in arch_defaults:
+ merge(config, arch_defaults[system_name])
+
+ return config
+
+
+def load_system_defaults(system_name: str, /) -> dict[str, Any]:
+ defaults = load_defaults(system_name, DEFAULTS_DIR / 'config.yml', DEFAULTS_DIR / 'config-arch.yml')
+
+ # In the yaml files, the "options" structure is not flat, so we have to flatten it here
+ # because the options are flat in batocera.conf to make it easier for end users to edit
+ data: dict[str, Any] = {'emulator': defaults['emulator'], 'core': defaults['core']}
+
+ if 'options' in defaults:
+ merge(data, defaults['options'])
+
+ return data
diff --git a/python-src/batocera-launch/batocera_launch/config/es_settings.py b/python-src/batocera-launch/batocera_launch/config/es_settings.py
new file mode 100644
index 00000000000..68b236c619f
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/config/es_settings.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+
+from ..paths import ES_SETTINGS
+
+
+@dataclass(slots=True)
+class ESSettings:
+ document: ET.ElementTree[ET.Element[str]]
+
+ def __get_value(self, key: str, type: str, /) -> str | None:
+ node = self.document.find(f'./{type}[@name="{key}"]')
+
+ if node is None:
+ return None
+
+ return node.attrib['value']
+
+ def get_bool(self, key: str, /, default: bool = False) -> bool:
+ value = self.__get_value(key, 'bool')
+
+ if value not in {'false', 'true'}:
+ return default
+
+ return value == 'true'
+
+ def get_str(self, key: str, /, default: str | None = None) -> str | None:
+ value = self.__get_value(key, 'string')
+
+ if value is None:
+ return default
+
+ return value
+
+ def get_int(self, key: str, /, default: int | None = None) -> int | None:
+ value = self.__get_value(key, 'int')
+
+ if value is None:
+ return default
+
+ return int(value)
+
+ @classmethod
+ def load(cls) -> ESSettings:
+ try:
+ document = ET.parse(ES_SETTINGS)
+ except Exception:
+ document = ET.ElementTree[ET.Element[str]](ET.Element('config'))
+
+ return cls(document=document)
diff --git a/python-src/batocera-launch/batocera_launch/config/labwc.py b/python-src/batocera-launch/batocera_launch/config/labwc.py
new file mode 100644
index 00000000000..0784b43bed3
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/config/labwc.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import logging
+import subprocess
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.paths import HOME
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+ from ..types import ScreenInfo
+
+_logger = logging.getLogger(__name__)
+
+RC_XML: Final = HOME / '.config' / 'labwc' / 'rc.xml'
+LABWC_BIN: Final = Path('/usr/bin/labwc')
+
+
+def _remove_action(window_rule: ET.Element[str], action_name: str, /) -> bool:
+ found = False
+
+ for action in window_rule.findall(f'./action[@name="{action_name}"]'):
+ window_rule.remove(action)
+ found = True
+
+ return found
+
+
+def _set_action(
+ window_rule: ET.Element[str],
+ action_name: str,
+ /,
+ text: str | None = None,
+ child: tuple[str, str] | None = None,
+ attributes: Mapping[str, str] | None = None,
+) -> None:
+ action = window_rule.find(f'./action[@name="{action_name}"]')
+
+ if action is None:
+ action = ET.SubElement(window_rule, 'action', {'name': action_name})
+
+ if attributes is not None:
+ for key, value in attributes.items():
+ action.set(key, value)
+
+ if text is not None:
+ action.text = text
+ elif child is not None:
+ child_tag, child_text = child
+
+ child_element = action.find(f'./{child_tag}')
+
+ if child_element is None:
+ child_element = ET.SubElement(action, child_tag)
+
+ child_element.text = child_text
+
+
+@dataclass(slots=True)
+class LabWCConfig:
+ path: Path = RC_XML
+ tree: ET.ElementTree[ET.Element[str]] = field(init=False)
+ root: ET.Element[str] = field(init=False)
+
+ _window_rules_cache: dict[tuple[str | None, str | None], ET.Element[str]] = field(init=False)
+ _window_rules_element: ET.Element[str] | None = field(init=False, default=None)
+
+ def __post_init__(self) -> None:
+ try:
+ self.tree = ET.parse(self.path)
+ self.root = self.tree.getroot()
+ except FileNotFoundError:
+ _logger.exception('file %s not found.', self.path)
+ raise
+ except ET.ParseError:
+ _logger.exception('file %s is not a valid XML file.', self.path)
+ raise
+
+ self._window_rules_cache = {}
+
+ def _get_window_rules_element(self) -> ET.Element[str]:
+ if self._window_rules_element is None:
+ self._window_rules_element = self.root.find('./windowRules')
+
+ if self._window_rules_element is None:
+ self._window_rules_element = ET.SubElement(self.root, 'windowRules')
+
+ return self._window_rules_element
+
+ def _get_window_rule(self, /, *, identifier: str | None = None, title: str | None = None) -> ET.Element[str]:
+ if identifier is None and title is None:
+ raise ValueError('Either identifier or title must be provided.')
+
+ window_rule = self._window_rules_cache.get((identifier, title))
+
+ if window_rule is None:
+ attribute_query = ''
+
+ if identifier is not None:
+ attribute_query = f'[@identifier="{identifier}"]'
+
+ if title is not None:
+ attribute_query = f'{attribute_query}[@title="{title}"]'
+
+ window_rule = self.root.find(f'./windowRules/windowRule{attribute_query}')
+
+ if window_rule is None:
+ window_rule = ET.SubElement(self._get_window_rules_element(), 'windowRule')
+
+ if identifier is not None:
+ window_rule.set('identifier', identifier)
+
+ if title is not None:
+ window_rule.set('title', title)
+
+ self._window_rules_cache[(identifier, title)] = window_rule
+
+ return window_rule
+
+ def set_move_to_output(
+ self, /, *, identifier: str | None = None, title: str | None = None, output: ScreenInfo | None
+ ) -> None:
+ window_rule = self._get_window_rule(identifier=identifier, title=title)
+
+ if output is None:
+ _remove_action(window_rule, 'MoveToOutput')
+ else:
+ _set_action(window_rule, 'MoveToOutput', child=('output', output.name))
+
+ def set_toggle_fullscreen(
+ self, /, *, identifier: str | None = None, title: str | None = None, fullscreen: bool = True
+ ) -> None:
+ window_rule = self._get_window_rule(identifier=identifier, title=title)
+
+ if not fullscreen:
+ _remove_action(window_rule, 'ToggleFullscreen')
+ else:
+ _set_action(window_rule, 'ToggleFullscreen')
+
+ def save(self) -> None:
+ ET.indent(self.tree, space=' ')
+ self.tree.write(self.path, encoding='utf-8', xml_declaration=True, short_empty_elements=True)
+
+ try:
+ subprocess.run([LABWC_BIN, '--reconfigure'], check=True)
+ except subprocess.CalledProcessError:
+ _logger.exception('failed to reconfigure labwc.')
diff --git a/python-src/batocera-launch/batocera_launch/config/metadata.py b/python-src/batocera-launch/batocera_launch/config/metadata.py
new file mode 100644
index 00000000000..b5daa986f84
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/config/metadata.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+import logging
+import xml.etree.ElementTree as ET
+from typing import TYPE_CHECKING, Final
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from ..rom import Rom
+
+_logger = logging.getLogger(__name__)
+
+# hardcoded list of system for arcade
+# this list can be found in es_system.yml
+# at this stage we don't know if arcade will be kept as one system only in metadata, so i hardcode this list for now
+_ARCADE_SYSTEMS: Final = {
+ 'lindbergh',
+ 'naomi',
+ 'naomi2',
+ 'atomiswave',
+ 'fbneo',
+ 'mame',
+ 'neogeo',
+ 'triforce',
+ 'hypseus-singe',
+ 'model2',
+ 'model3',
+ 'hikaru',
+ 'gaelco',
+ 'cave3rd',
+ 'namco2x6',
+ 'namco22',
+}
+
+
+def _update_metadata_from_element(md: dict[str, str], element: ET.Element, /, extra_log_text: str = '') -> None:
+ for child in element:
+ for attrib_name, attrib_value in child.attrib.items():
+ key = f'{child.tag}_{attrib_name}'
+ md[key] = attrib_value
+ _logger.info('found game metadata %s=%s%s', key, attrib_value, extra_log_text)
+
+
+def get_games_meta_data(db_xml: str | Path, system: str, rom: Rom) -> dict[str, str]:
+ # load the database
+ tree = ET.parse(db_xml)
+ root: ET.Element = tree.getroot()
+ game = rom.short_id
+ md: dict[str, str] = {}
+
+ _logger.info('looking for game metadata (%s, %s) in %s', system, game, db_xml)
+
+ target_system = 'arcade' if system in _ARCADE_SYSTEMS else system
+
+ for system_element in root.iterfind('./system[@id]'):
+ if target_system not in system_element.attrib['id'].split(','):
+ continue
+
+ # search the game named default
+ if (default_element := system_element.find('./game[@id="default"]')) is not None:
+ _update_metadata_from_element(md, default_element, extra_log_text=' (system level)')
+
+ for game_element in system_element.iterfind('./game[@id!="default"]'):
+ if game_element.attrib['id'] not in game:
+ continue
+ _update_metadata_from_element(md, game_element)
+ return md
+
+ return md
diff --git a/python-src/batocera-launch/batocera_launch/devices/__init__.py b/python-src/batocera-launch/batocera_launch/devices/__init__.py
new file mode 100644
index 00000000000..47eee215930
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/__init__.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from .controller import (
+ Controller as Controller,
+ ControllerList as ControllerList,
+ Controllers as Controllers,
+)
+from .gun import (
+ Gun as Gun,
+ GunList as GunList,
+ Guns as Guns,
+ guns_need_crosses as guns_need_crosses,
+)
diff --git a/python-src/batocera-launch/batocera_launch/devices/controller.py b/python-src/batocera-launch/batocera_launch/devices/controller.py
new file mode 100644
index 00000000000..89953a151db
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/controller.py
@@ -0,0 +1,282 @@
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+from collections.abc import Container, Iterable, Mapping, Sequence
+from dataclasses import InitVar, dataclass, field, replace
+from pathlib import Path
+from typing import TYPE_CHECKING, Final, Literal, Self, TypedDict, Unpack, cast
+
+from batocera_common.paths import HOME
+
+from ..exceptions import BatoceraException
+from ..paths import SYSTEM_ES_DIR, USER_ES_DIR
+from .input import Input, InputDict, InputMapping
+
+if TYPE_CHECKING:
+ from ..cli.arguments import PlayerArguments
+
+"""Default mapping of Batocera keys to SDL_GAMECONTROLLERCONFIG keys."""
+_DEFAULT_SDL_MAPPING: Final = {
+ 'b': 'a',
+ 'a': 'b',
+ 'x': 'y',
+ 'y': 'x',
+ 'l2': 'lefttrigger',
+ 'r2': 'righttrigger',
+ 'l3': 'leftstick',
+ 'r3': 'rightstick',
+ 'pageup': 'leftshoulder',
+ 'pagedown': 'rightshoulder',
+ 'start': 'start',
+ 'select': 'back',
+ 'up': 'dpup',
+ 'down': 'dpdown',
+ 'left': 'dpleft',
+ 'right': 'dpright',
+ 'joystick1up': 'lefty',
+ 'joystick1left': 'leftx',
+ 'joystick2up': 'righty',
+ 'joystick2left': 'rightx',
+ 'hotkey': 'guide',
+}
+
+
+def _key_to_sdl_game_controller_config(keyname: str, input: Input, /) -> str | None:
+ """
+ Converts a key mapping to the SDL_GAMECONTROLLER format.
+
+ Arguments:
+ keyname: (str) SDL_GAMECONTROLLERCONFIG input name.
+ input: (Input) input object.
+ Returns:
+ (str) SDL_GAMECONTROLLERCONFIG-formatted key mapping string.
+ """
+ if input.type == 'button':
+ return f'{keyname}:b{input.id}'
+
+ if input.type == 'hat':
+ return f'{keyname}:h{input.id}.{input.value}'
+
+ if input.type == 'axis':
+ if 'joystick' in input.name:
+ return f'{keyname}:a{input.id}{"~" if int(input.value) > 0 else ""}'
+
+ if keyname in ('dpup', 'dpdown', 'dpleft', 'dpright'):
+ return f'{keyname}:{"-" if int(input.value) < 0 else "+"}a{input.id}'
+
+ if 'trigger' in keyname:
+ return f'{keyname}:a{input.id}{"~" if int(input.value) < 0 else ""}'
+
+ return f'{keyname}:a{input.id}'
+
+ if input.type == 'key':
+ return None
+
+ raise BatoceraException(f'Unknown controller input type: {input.type!r}')
+
+
+def _find_input_config(roots: Iterable[ET.Element], name: str, guid: str, /) -> ET.Element:
+ path = './inputConfig'
+
+ for root in roots:
+ element = root.find(f'{path}[@deviceGUID="{guid}"][@deviceName="{name}"]')
+ if element is not None:
+ return element
+
+ for root in roots:
+ element = root.find(f'{path}[@deviceGUID="{guid}"]')
+ if element is not None:
+ return element
+
+ for root in roots:
+ element = root.find(f'{path}[@deviceName="{name}"]')
+ if element is not None:
+ return element
+
+ raise BatoceraException(f'Could not find controller data for "{name}" with GUID "{guid}"')
+
+
+class _RelaxedDict(TypedDict):
+ centered: bool
+ reversed: bool
+
+
+class _ControllerChanges(TypedDict, total=False):
+ guid: str
+ player_number: int
+ index: int
+ real_name: str
+ device_path: str
+ button_count: int
+ hat_count: int
+ axis_count: int
+ physical_device_path: str | None
+ physical_index: int | None
+
+
+@dataclass(slots=True, kw_only=True)
+class Controller:
+ name: str
+ type: Literal['keyboard', 'joystick']
+ guid: str
+ player_number: int # when this is filled out, it will start at 1
+ index: int
+ real_name: str
+ device_path: str
+ button_count: int
+ hat_count: int
+ axis_count: int
+ physical_device_path: str | None = None
+ physical_index: int | None = None
+
+ inputs_: InitVar[InputMapping | Iterable[tuple[str, Input]] | None] = None
+ inputs: InputDict = field(init=False)
+
+ def __post_init__(self, inputs_: InputMapping | Iterable[tuple[str, Input]] | None, /) -> None:
+ self.inputs = dict(inputs_) if inputs_ is not None else {}
+
+ def replace(self, /, **changes: Unpack[_ControllerChanges]) -> Self:
+ return replace(self, **changes, inputs_={name: input.replace() for name, input in self.inputs.items()})
+
+ def generate_sdl_game_db_line(
+ self, sdl_mapping: Mapping[str, str] = _DEFAULT_SDL_MAPPING, /, ignore_buttons: Container[str] | None = None
+ ) -> str:
+ """Returns an SDL_GAMECONTROLLERCONFIG-formatted string for the given configuration."""
+ config = [self.guid, self.real_name.replace(',', '.'), 'platform:Linux']
+
+ def add_mapping(input: Input) -> None:
+ key_name = sdl_mapping.get(input.name, None)
+ if key_name is None:
+ return
+ sdl_config = _key_to_sdl_game_controller_config(key_name, input)
+ if sdl_config is not None:
+ config.append(sdl_config)
+
+ # "hotkey" is often mapped to an existing button but such a duplicate mapping
+ # confuses SDL apps. We add "hotkey" mapping only if its target isn't also mapped elsewhere.
+ hotkey_input: Input | None = None
+ mapped_button_ids: set[str] = set()
+
+ for input in self.inputs.values():
+ if not input.name is not None: # pragma: no cover # pyright: ignore[reportUnnecessaryComparison]
+ continue
+ if ignore_buttons is not None and input.name in ignore_buttons:
+ continue
+ if input.name == 'hotkey':
+ hotkey_input = input
+ continue
+ if input.type == 'button':
+ mapped_button_ids.add(input.id)
+
+ add_mapping(input)
+
+ if hotkey_input is not None and hotkey_input.id not in mapped_button_ids:
+ add_mapping(hotkey_input)
+
+ config.append('')
+
+ return ','.join(config)
+
+ def get_mapping_axis_relaxed_values(self) -> dict[str, _RelaxedDict]:
+ import evdev
+
+ # read the sdl2 cache if possible for axis
+ cache_file = Path(HOME / '.sdl2' / f'{self.guid}_{self.name}.cache')
+ if not cache_file.exists():
+ return {}
+
+ cache_content = cache_file.read_text(encoding='utf-8').splitlines()
+ n = int(cache_content[0]) # number of lines of the cache
+
+ relaxed_values: list[int] = [int(cache_content[i]) for i in range(1, n + 1)]
+
+ # get full list of axis (in case one is not used in es)
+ caps = evdev.InputDevice(self.device_path).capabilities()
+ code_values: dict[int, int] = {}
+ i = 0
+ for code, _ in caps[evdev.ecodes.EV_ABS]:
+ if code < evdev.ecodes.ABS_HAT0X:
+ code_values[code] = relaxed_values[i]
+ i = i + 1
+
+ # dict with es input names
+ res: dict[str, _RelaxedDict] = {}
+ for x, input in self.inputs.items():
+ if input.type == 'axis':
+ # sdl values : from -32000 to 32000 / do not put < 0 cause a wheel/pad could be not correctly centered
+ # 3 possible initial positions <1----------------|-------2-------|----------------3>
+ if (val := code_values.get(int(cast('str', input.code)))) is not None:
+ res[x] = {'centered': val > -4000 and val < 4000, 'reversed': val > 4000}
+ else:
+ res[x] = {'centered': True, 'reversed': False}
+ return res
+
+ # Create a controller array with the player id as a key
+ @classmethod
+ def load_for_players(cls, player_args: Iterable[PlayerArguments | None], /) -> ControllerList:
+ cfg_roots = [
+ ET.parse(conffile).getroot()
+ for conffile in (USER_ES_DIR / 'es_input.cfg', SYSTEM_ES_DIR / 'es_input.cfg')
+ if conffile.exists()
+ ]
+
+ return [
+ controller
+ for player_number, player_arg in enumerate(player_args, start=1)
+ if player_arg is not None
+ and (controller := cls._find_best_controller(cfg_roots, player_arg, player_number)) is not None
+ ]
+
+ @classmethod
+ def _find_best_controller(
+ cls,
+ roots: Iterable[ET.Element],
+ player_arg: PlayerArguments,
+ player_number: int,
+ /,
+ ) -> Controller | None:
+ input_config = _find_input_config(roots, player_arg.name, player_arg.guid)
+ return cls(
+ name=cast('str', input_config.get('deviceName')),
+ type=cast('Literal["keyboard", "joystick"]', input_config.get('type')),
+ guid=player_arg.guid,
+ inputs_=Input.from_parent_element(input_config),
+ player_number=player_number,
+ index=player_arg.index,
+ real_name=player_arg.name,
+ device_path=player_arg.devicepath,
+ button_count=player_arg.nbbuttons,
+ hat_count=player_arg.nbhats,
+ axis_count=player_arg.nbaxes,
+ )
+
+ @staticmethod
+ def find_player_number(controllers: Controllers, player_number: int, /) -> Controller | None:
+ for controller in controllers:
+ if controller.player_number == player_number:
+ return controller
+
+ return None
+
+
+def generate_sdl_game_controller_config(
+ controllers: Controllers, /, ignore_buttons: Container[str] | None = None
+) -> str:
+ return '\n'.join(controller.generate_sdl_game_db_line(ignore_buttons=ignore_buttons) for controller in controllers)
+
+
+def write_sdl_controller_db(
+ controllers: Controllers,
+ outputFile: str | Path = '/tmp/gamecontrollerdb.txt',
+ /,
+) -> Path:
+ outputFile = Path(outputFile)
+
+ with outputFile.open('w') as text_file:
+ text_file.write(generate_sdl_game_controller_config(controllers))
+
+ return outputFile
+
+
+type Controllers = Sequence[Controller]
+type ControllerList = list[Controller]
diff --git a/python-src/batocera-launch/batocera_launch/devices/device.py b/python-src/batocera-launch/batocera_launch/devices/device.py
new file mode 100644
index 00000000000..894efe0c923
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/device.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import logging
+import re
+from collections.abc import Mapping
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final, NotRequired, TypedDict, cast
+
+_logger: Final = logging.getLogger(__name__)
+
+
+@dataclass(slots=True)
+class DeviceInfo:
+ event_id: int
+ sysfs_path: str
+ is_joystick: bool
+ is_wheel: bool
+ is_mouse: bool
+ associated_devices: list[str] | None
+ joystick_index: int | None
+ mouse_index: int | None
+ wheel_rotation: int | None = None
+
+
+type DeviceInfoMapping = Mapping[str, DeviceInfo]
+type DeviceInfoDict = dict[str, DeviceInfo]
+
+
+def input_event_path_to_id(dev: str | None, /) -> int | None:
+ if dev is None:
+ return None
+
+ matches = re.match(r'^/dev/input/event([0-9]*)$', dev)
+ if matches is None:
+ return None
+
+ return int(matches.group(1))
+
+
+class _Device(TypedDict):
+ node: str
+ sysfs_path: str
+ group: str | None
+ isJoystick: bool
+ isWheel: bool
+ isMouse: bool
+ wheel_rotation: NotRequired[int]
+
+
+def get_device_info() -> DeviceInfoDict:
+ import pyudev
+
+ groups: dict[str | None, list[str]] = {}
+ devices: dict[int, _Device] = {}
+ context = pyudev.Context()
+ events = context.list_devices(subsystem='input')
+ mouses: list[int] = []
+ joysticks: list[int] = []
+ for ev in events:
+ eventId = input_event_path_to_id(str(ev.device_node))
+ if eventId is not None:
+ isJoystick = 'ID_INPUT_JOYSTICK' in ev.properties and ev.properties['ID_INPUT_JOYSTICK'] == '1'
+ isWheel = 'ID_INPUT_WHEEL' in ev.properties and ev.properties['ID_INPUT_WHEEL'] == '1'
+ isMouse = ('ID_INPUT_MOUSE' in ev.properties and ev.properties['ID_INPUT_MOUSE'] == '1') or (
+ 'ID_INPUT_TOUCHPAD' in ev.properties and ev.properties['ID_INPUT_TOUCHPAD'] == '1'
+ )
+ group = None
+ if 'ID_PATH' in ev.properties:
+ group = ev.properties['ID_PATH']
+ if isJoystick or isMouse:
+ if isJoystick:
+ joysticks.append(eventId)
+ if isMouse:
+ mouses.append(eventId)
+ devices[eventId] = {
+ 'node': cast('str', ev.device_node),
+ 'sysfs_path': str((Path(ev.sys_path) / 'device' / 'device').resolve()),
+ 'group': group,
+ 'isJoystick': isJoystick,
+ 'isWheel': isWheel,
+ 'isMouse': isMouse,
+ }
+ if 'ID_PATH' in ev.properties:
+ if isWheel and 'WHEEL_ROTATION_ANGLE' in ev.properties:
+ devices[eventId]['wheel_rotation'] = int(ev.properties['WHEEL_ROTATION_ANGLE'])
+ if group not in groups:
+ groups[group] = []
+ groups[group].append(cast('str', ev.device_node))
+ mouses.sort()
+ joysticks.sort()
+ res: DeviceInfoDict = {}
+ for device in devices:
+ d = devices[device]
+ dgroup = None
+ if d['group'] is not None:
+ dgroup = groups[d['group']].copy()
+ dgroup.remove(d['node'])
+ nmouse = None
+ njoystick = None
+ if d['isJoystick']:
+ njoystick = joysticks.index(device)
+ nmouse = None
+ if d['isMouse']:
+ nmouse = mouses.index(device)
+ res[d['node']] = DeviceInfo(
+ event_id=device,
+ sysfs_path=d['sysfs_path'],
+ is_joystick=d['isJoystick'],
+ is_wheel=d['isWheel'],
+ is_mouse=d['isMouse'],
+ associated_devices=dgroup,
+ joystick_index=njoystick,
+ mouse_index=nmouse,
+ )
+ if 'wheel_rotation' in d:
+ res[d['node']].wheel_rotation = d['wheel_rotation']
+ return res
+
+
+def get_associated_mouse(devices_information: DeviceInfoMapping, dev: str, /) -> str | None:
+ device = devices_information.get(dev)
+ if device is None or device.associated_devices is None:
+ return None
+ for candidate in device.associated_devices:
+ if devices_information[candidate].is_mouse:
+ return candidate
+ return None
diff --git a/python-src/batocera-launch/batocera_launch/devices/evmapy.py b/python-src/batocera-launch/batocera_launch/devices/evmapy.py
new file mode 100644
index 00000000000..575cd1efbc8
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/evmapy.py
@@ -0,0 +1,690 @@
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+import logging
+from collections import defaultdict
+from contextlib import AbstractAsyncContextManager
+from dataclasses import InitVar, dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Final, Literal, NotRequired, Self, TypedDict, cast
+
+from batocera_common.asyncio import create_ready_task, run
+from batocera_common.paths import CONFIGS
+
+from ..paths import EVMAPY, EVMAPY_SHARE_DIR
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator, Container, Mapping
+ from types import TracebackType
+
+ from ..emulator import Emulator
+ from .controller import Controller
+ from .gun import Gun, Guns
+
+_logger = logging.getLogger(__name__)
+
+_EVMAPY_RUN_DIR: Final = Path('/var/run/evmapy')
+
+
+class _EvmapyAction(TypedDict):
+ trigger: str | list[str]
+ type: Literal['exec', 'key', 'mouse']
+ target: str | list[str]
+ mode: NotRequired[Literal['all', 'sequence', 'any']]
+ hold: NotRequired[float]
+
+
+class _EvmapyButton(TypedDict):
+ name: str
+ code: int
+
+
+class _EvmapyAxis(_EvmapyButton):
+ min: float
+ max: float
+
+
+class _EvmapyConfig(TypedDict):
+ actions: list[_EvmapyAction]
+ grab: bool
+ axes: NotRequired[list[_EvmapyAxis]]
+ buttons: NotRequired[list[_EvmapyButton]]
+
+
+class _KeysActionBase(TypedDict):
+ trigger: str | list[str]
+ mode: NotRequired[Literal['all', 'sequence', 'any']]
+ hold: NotRequired[float]
+ description: NotRequired[str]
+
+
+class _KeysAction(_KeysActionBase):
+ type: Literal['exec', 'key']
+ target: str | list[str]
+
+
+class _KeysMouseAction(_KeysActionBase):
+ type: Literal['mouse']
+
+
+type _KeysActions = list[_KeysAction | _KeysMouseAction]
+type _KeysConfig = dict[str, _KeysActions]
+
+
+def _keys_action_to_evmapy_action(keys_action: _KeysAction, /) -> _EvmapyAction:
+ return cast('_EvmapyAction', {key: value for key, value in keys_action.items() if key != 'description'})
+
+
+def _keys_mouse_action_to_evmapy_action(
+ keys_action: _KeysMouseAction, /, *, trigger: str, target: str
+) -> _EvmapyAction:
+ return cast(
+ '_EvmapyAction',
+ {
+ **{key: value for key, value in keys_action.items() if key != 'description'},
+ 'trigger': trigger,
+ 'target': target,
+ },
+ )
+
+
+@dataclass(slots=True)
+class EvmapyManager(AbstractAsyncContextManager['EvmapyManager', None]):
+ # evmapy is a process that map pads to keyboards (for pygame for example)
+ __started: bool = field(init=False, default=False)
+ system_name: str = field(init=False)
+ emulator: str = field(init=False)
+ core: str = field(init=False)
+ rom: Path = field(init=False)
+ controllers: list[Controller | None] = field(init=False)
+ guns: Guns = field(init=False)
+
+ _emulator: InitVar[Emulator]
+
+ def __post_init__(self, _emulator: Emulator) -> None:
+ self.system_name = _emulator.system
+ self.emulator = _emulator.name
+ self.core = _emulator.core
+ self.rom = _emulator.rom.source
+ self.controllers = list(_emulator.controllers) # type: ignore[assignment]
+ self.guns = _emulator.guns
+
+ async def __aenter__(self) -> Self:
+ if await self.__prepare():
+ self.__started = True
+ await run('batocera-evmapy', 'start', capture_output=False)
+
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: TracebackType | None,
+ /,
+ ) -> None:
+ if self.__started:
+ self.__started = False
+ await run('batocera-evmapy', 'stop', capture_output=False)
+ await run('batocera-evmapy', 'clear', capture_output=False)
+
+ @contextlib.asynccontextmanager
+ async def monitor_controllers(self) -> AsyncGenerator[None]:
+ task = await create_ready_task(self._monitor_controllers_loop)
+ try:
+ yield
+ finally:
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+
+ async def _monitor_controllers_loop(self, ready: asyncio.Event) -> None:
+ # Runs in the background, watching for controller add/remove events.
+ # Uses our sdl3 ctypes wrapper to reliably get controller GUIDs and paths, then intelligently "revives"
+ # the original controller object to preserve player order without disrupting the emulator.
+
+ import pyudev
+
+ from batocera_common import sdl3
+
+ initial_controllers_snapshot = list(self.controllers)
+ for i, p_controller in enumerate(initial_controllers_snapshot):
+ if p_controller and p_controller.guid:
+ _logger.info(
+ '>>> [P%s] Stored GUID: %s, Initial Path: %s', i + 1, p_controller.guid, p_controller.device_path
+ )
+
+ we_initialized_sdl = False
+ try:
+ if not sdl3.was_init(sdl3.InitFlags.JOYSTICK):
+ _logger.info('>>> SDL3 joystick subsystem not initialized. Initializing it now.')
+ sdl3.init(sdl3.InitFlags.JOYSTICK)
+ we_initialized_sdl = True
+ else:
+ _logger.info(
+ '>>> SDL3 joystick subsystem already initialized by host (emulator). Will not re-initialize.'
+ )
+ except Exception as e:
+ _logger.error('FATAL: Could not initialize SDL3 for controller monitoring: %s', e)
+ ready.set()
+ return
+
+ context = pyudev.Context()
+ monitor = pyudev.Monitor.from_netlink(context)
+ monitor.filter_by(subsystem='input')
+
+ loop = asyncio.get_running_loop()
+
+ _logger.info('>>> Starting background controller monitor.')
+
+ ready.set()
+ try:
+ while True:
+ device = await loop.run_in_executor(None, monitor.poll, None)
+ if device is None:
+ break
+
+ if device.properties.get('ID_INPUT_JOYSTICK') != '1':
+ continue
+
+ _logger.info('--- Joystick Event Detected: %s on %s ---', device.action, device.sys_path)
+ reconfigure_needed = False
+
+ sdl3.update_joysticks()
+ joystick_ids = sdl3.get_joystick_ids()
+
+ online_controllers_map: dict[str, Path] = {}
+
+ for joystick_id in joystick_ids:
+ try:
+ guid = sdl3.get_joystick_guid(joystick_id)
+ path = sdl3.get_joystick_path(joystick_id)
+
+ if guid and path:
+ online_controllers_map[guid] = path
+ except Exception as e:
+ _logger.warning('Error while querying joystick id %s with sdl3: %s', joystick_id, e)
+
+ _logger.info('>>> [Check 1] SDL3 scan found online controllers: %s', online_controllers_map)
+
+ new_active_controllers = cast('list[Controller | None]', [None] * len(initial_controllers_snapshot))
+
+ for i, initial_controller in enumerate(initial_controllers_snapshot):
+ if initial_controller and initial_controller.guid in online_controllers_map:
+ new_path = online_controllers_map[initial_controller.guid]
+ if initial_controller.device_path != new_path:
+ _logger.info(
+ '>>> [Revival] Player %s (GUID: %s) path has changed.',
+ initial_controller.player_number,
+ initial_controller.guid,
+ )
+ initial_controller.device_path = str(new_path)
+ new_active_controllers[i] = initial_controller
+
+ current_paths = [c.device_path if c else None for c in self.controllers]
+ new_paths = [c.device_path if c else None for c in new_active_controllers]
+
+ if current_paths != new_paths:
+ _logger.info(
+ '>>> [Check 2] Controller state changed. Old Paths: %s. New Paths: %s',
+ current_paths,
+ new_paths,
+ )
+ self.controllers = new_active_controllers
+ reconfigure_needed = True
+ else:
+ _logger.info('>>> [Check 2] No change in assigned controller paths detected.')
+
+ if reconfigure_needed:
+ await asyncio.sleep(1)
+ await self.__reconfigure()
+ finally:
+ if we_initialized_sdl:
+ sdl3.quit_subsystem(sdl3.InitFlags.JOYSTICK)
+
+ def __build_merged_keys_file(self) -> Path | None:
+ # consider files here in this order to get a configuration
+ files_to_merge = [
+ keys_file
+ for keys_file in [
+ # {rom}.keys form is forbidden for directories, it must be inside
+ (self.rom.parent / f'{self.rom.name}.keys') if not self.rom.is_dir() else (self.rom / 'padto.keys'),
+ # EVMAPY / f"{self.system}.{self.emulator}.{self.core}.keys",
+ # EVMAPY / f"{self.system}.{self.emulator}.keys",
+ EVMAPY / f'{self.system_name}.keys',
+ EVMAPY / f'{self.emulator}.keys',
+ EVMAPY / 'any.keys',
+ # _EVMAPY_SHARE_DIR / f"{self.system}.{self.emulator}.{self.core}.keys" ,
+ EVMAPY_SHARE_DIR / f'{self.system_name}.{self.emulator}.keys',
+ EVMAPY_SHARE_DIR / f'{self.system_name}.keys',
+ EVMAPY_SHARE_DIR / f'{self.emulator}.keys',
+ EVMAPY_SHARE_DIR / 'any.keys',
+ ]
+ if keys_file.exists()
+ ]
+
+ # merge conditionnally on the global hotkeys file until it is set everywhere
+ keys_file = EVMAPY_SHARE_DIR / 'hotkeys.keys'
+ user_keys_file = CONFIGS / 'hotkeys.keys' # prefer the custom one
+
+ if user_keys_file.exists():
+ keys_file = user_keys_file
+
+ if keys_file.exists():
+ files_to_merge.append(keys_file)
+
+ if not files_to_merge:
+ _logger.debug('no files to merge')
+ return None
+
+ _logger.debug('files to merge : %s', files_to_merge)
+
+ if len(files_to_merge) == 1:
+ return files_to_merge[0]
+
+ merged_file = Path('/var/run/evmapy_merged.keys')
+
+ merged_unique_values: dict[str, dict[str, _KeysAction | _KeysMouseAction]] = defaultdict(dict)
+
+ for file in files_to_merge:
+ values: _KeysConfig = json.loads(file.read_text())
+
+ for player_actions in values:
+ for action in values[player_actions]:
+ # merge multiple trigger keys list in a single ordered key
+ if isinstance(action['trigger'], list):
+ action['trigger'].sort()
+ trigger = '-'.join(action['trigger'])
+ else:
+ trigger = action['trigger']
+
+ if trigger not in merged_unique_values[player_actions]:
+ merged_unique_values[player_actions][trigger] = action
+
+ merged_values: _KeysConfig = defaultdict(list)
+
+ for player_actions in merged_unique_values:
+ for action in merged_unique_values[player_actions]:
+ merged_values[player_actions].append(merged_unique_values[player_actions][action])
+
+ merged_file.write_text(json.dumps(merged_values, indent=2))
+
+ return merged_file
+
+ async def __prepare(self) -> bool:
+ keys_file = self.__build_merged_keys_file()
+
+ if keys_file is None:
+ # otherwise, preparation did nothing
+ _logger.debug('no evmapy config file found for system=%s, emulator=%s', self.system_name, self.emulator)
+ return False
+
+ _logger.debug('evmapy on %s', keys_file)
+ await run('batocera-evmapy', 'clear', capture_output=False)
+
+ pad_action_config: _KeysConfig = json.loads(keys_file.read_text())
+
+ # configure guns
+ for ngun, gun in enumerate(self.guns, start=1):
+ if (actions := pad_action_config.get(f'actions_gun{ngun}')) is not None:
+ self.__write_gun_config(gun, actions, keys_file)
+
+ # configure each player
+ for pad in self.controllers:
+ if pad is None:
+ continue
+ if (actions := pad_action_config.get(f'actions_player{pad.player_number}')) is not None:
+ self.__write_controller_config(pad, actions, keys_file)
+
+ return True
+
+ async def __reconfigure(self) -> None:
+ # Re-runs the evmapy configuration by stopping the current evmapy, setting the new controllers, and restarting it
+ _logger.info('>>> --- STARTING EVMAPY RECONFIGURATION ---')
+
+ valid_controllers = [c for c in self.controllers if c is not None]
+ _logger.info('>>> Found %s valid controllers to configure.', len(valid_controllers))
+ for c in valid_controllers:
+ _logger.info('>>> - Configuring P%s with Path: %s', c.player_number, c.device_path)
+
+ await run('batocera-evmapy', 'stop', capture_output=False)
+ await asyncio.sleep(0.5)
+ await self.__prepare()
+ await run('batocera-evmapy', 'start', capture_output=False)
+
+ _logger.info('>>> --- EVMAPY RECONFIGURATION COMPLETE ---')
+
+ def __write_gun_config(self, gun: Gun, actions: _KeysActions, keys_file: Path, /) -> None:
+ config_file = _EVMAPY_RUN_DIR / f'{Path(gun.node).name}.json'
+ _logger.debug('config file for keysfile is %s (from %s) - gun', config_file, keys_file)
+
+ evmapy_config: _EvmapyConfig = {
+ 'buttons': [{'name': button, 'code': code} for button, code in gun.button_map.items()],
+ 'axes': [],
+ 'actions': [
+ {**_keys_action_to_evmapy_action(action), 'trigger': gun_trigger}
+ for action in actions
+ if 'trigger' in action
+ and 'type' in action
+ and action['type'] != 'mouse'
+ and 'target' in action
+ and (gun_trigger := self.__get_gun_trigger(action['trigger'], gun))
+ ],
+ 'grab': False,
+ }
+
+ config_file.write_text(json.dumps(evmapy_config, indent=2))
+
+ def __write_controller_config(self, controller: Controller, keys_actions: _KeysActions, keys_file: Path, /) -> None:
+ config_file = _EVMAPY_RUN_DIR / f'{Path(controller.device_path).name}.json'
+ _logger.debug('config file for keysfile is %s (from %s)', config_file, keys_file)
+
+ # create mapping
+ evmapy_config: _EvmapyConfig = {
+ 'axes': [],
+ 'buttons': [],
+ 'grab': False,
+ 'actions': [],
+ }
+
+ absbasex_positive = True
+ absbasey_positive = True
+
+ # define buttons / axes
+ known_button_names: set[str] = set()
+ known_button_codes: dict[str, str] = {}
+ known_button_aliases: dict[str, str] = {}
+ known_axis_codes: set[str] = set()
+
+ for input in controller.inputs.values():
+ if input.type == 'button':
+ # don't add the same button twice (ie select as hotkey)
+ if input.code is not None:
+ if input.code not in known_button_codes:
+ known_button_names.add(input.name)
+ known_button_codes[input.code] = input.name # keep the master name for aliases
+ evmapy_config['buttons'].append({'name': input.name, 'code': int(input.code)})
+ else:
+ known_button_aliases[input.name] = known_button_codes[input.code]
+ elif input.type == 'hat':
+ if (input_value := int(input.value)) in [1, 2]: # don't duplicate values
+ if input_value == 1:
+ name = 'X'
+ is_y_as_int = 0
+ else:
+ name = 'Y'
+ is_y_as_int = 1
+
+ known_button_names.add(f'HAT{input.id}{name}:min')
+ known_button_names.add(f'HAT{input.id}{name}:max')
+
+ evmapy_config['axes'].append(
+ {
+ 'name': f'HAT{input.id}{name}',
+ 'code': int(input.id) + 16 + is_y_as_int, # 16 = HAT0X in linux/input.h
+ 'min': -1,
+ 'max': 1,
+ }
+ )
+ elif input.type == 'axis': # noqa: SIM102
+ if input.code not in known_axis_codes and input.code is not None:
+ # avoid duplicated value for axis (bad pad configuration that make evmappy to stop)
+ known_axis_codes.add(input.code)
+
+ axis_id: str | None = None
+ axis_name: str | None = None
+
+ if input.name.startswith(('joystick1', 'joystick2')):
+ axis_id = '0' if input.name == 'joystick1up' or input.name == 'joystick1left' else '1'
+ axis_name = 'Y' if input.name == 'joystick1up' or input.name == 'joystick2up' else 'X'
+ elif input.name == 'up' or input.name == 'down':
+ axis_id = 'BASE'
+ axis_name = 'Y'
+ if input.name == 'up':
+ absbasey_positive = int(input.value) >= 0
+ else:
+ absbasey_positive = int(input.value) < 0
+ elif input.name == 'left' or input.name == 'right':
+ axis_id = 'BASE'
+ axis_name = 'X'
+ if input.name == 'left':
+ absbasex_positive = int(input.value) < 0
+ else:
+ absbasex_positive = int(input.value) >= 0
+ else:
+ axis_id = '_OTHERS_'
+ axis_name = input.name
+
+ if (axis_id and axis_name) or axis_id == '_OTHERS_':
+ axis_min, axis_max = self.__get_pad_min_max_axis(controller.device_path, int(input.code))
+
+ known_button_names.add(f'ABS{axis_id}{axis_name}:min')
+ known_button_names.add(f'ABS{axis_id}{axis_name}:max')
+ known_button_names.add(f'ABS{axis_id}{axis_name}:val')
+
+ evmapy_config['axes'].append(
+ {
+ 'name': f'ABS{axis_id}{axis_name}',
+ 'code': int(input.code),
+ 'min': axis_min,
+ 'max': axis_max,
+ }
+ )
+
+ # only add actions for which buttons are defined (otherwise, evmapy doesn't like it)
+ evmapy_actions: list[_EvmapyAction] = []
+ trigger_mapping = self.__get_mapping_for_triggers(known_button_names, absbasex_positive, absbasey_positive)
+
+ for keys_action in keys_actions:
+ if (
+ 'type' in keys_action
+ and keys_action['type'] == 'mouse'
+ and 'target' not in keys_action
+ and 'trigger' in keys_action
+ ):
+ # handle mouse events : only joystick1 or joystick2 defined for 2 events
+ if keys_action['trigger'] == 'joystick1':
+ evmapy_actions.append(
+ _keys_mouse_action_to_evmapy_action(keys_action, trigger='joystick1x', target='X')
+ )
+ evmapy_actions.append(
+ _keys_mouse_action_to_evmapy_action(keys_action, trigger='joystick1y', target='Y')
+ )
+ elif keys_action['trigger'] == 'joystick2':
+ evmapy_actions.append(
+ _keys_mouse_action_to_evmapy_action(keys_action, trigger='joystick2x', target='X')
+ )
+ evmapy_actions.append(
+ _keys_mouse_action_to_evmapy_action(keys_action, trigger='joystick2y', target='Y')
+ )
+ else:
+ evmapy_actions.append(_keys_action_to_evmapy_action(keys_action))
+
+ axis_for_mouse: set[str] = set()
+ trigger_mapping = self.__get_mapping_for_triggers(known_button_names, absbasex_positive, absbasey_positive)
+
+ # define actions
+ for evmapy_action in evmapy_actions:
+ if 'trigger' in evmapy_action:
+ trigger = self.__trigger_mapper(
+ evmapy_action['trigger'], known_button_aliases, known_button_names, trigger_mapping
+ )
+
+ if 'mode' not in evmapy_action:
+ mode = self.__trigger_mapper_mode(evmapy_action['trigger'])
+ if mode is not None:
+ evmapy_action['mode'] = mode
+
+ evmapy_action['trigger'] = trigger
+
+ if isinstance(trigger, list):
+ if (
+ # because of aliases (hotkeys), a button can be present 2 times => disable this key
+ all(x in known_button_names or f'ABS_OTHERS_{x}:max' in known_button_names for x in trigger)
+ and len(trigger) == len(set(trigger))
+ ):
+ # rewrite axis buttons
+ evmapy_action['trigger'] = [
+ f'ABS_OTHERS_{val}:max' if f'ABS_OTHERS_{val}:max' in known_button_names else val
+ for val in trigger
+ ]
+ evmapy_config['actions'].append(evmapy_action)
+ else:
+ if trigger in known_button_names:
+ evmapy_config['actions'].append(evmapy_action)
+ if f'ABS_OTHERS_{trigger}:max' in known_button_names:
+ evmapy_config['actions'].append(
+ {**evmapy_action, 'trigger': f'ABS_OTHERS_{evmapy_action["trigger"]}:max'}
+ )
+
+ # use full axis for mouse and 50% for keys
+ for evmapy_action in evmapy_config['actions']:
+ if 'type' in evmapy_action and evmapy_action['type'] == 'mouse':
+ if isinstance(evmapy_action['trigger'], list):
+ for x in evmapy_action['trigger']:
+ axis_for_mouse.add(x)
+ else:
+ axis_for_mouse.add(evmapy_action['trigger'])
+
+ for axis in evmapy_config['axes']:
+ if (
+ f'{axis["name"]}:val' not in axis_for_mouse
+ and f'{axis["name"]}:min' not in axis_for_mouse
+ and f'{axis["name"]}:max' not in axis_for_mouse
+ ):
+ axis['min'], axis['max'] = self.__get_pad_min_max_axis_for_keys(axis['min'], axis['max'])
+
+ # save config file
+ config_file.write_text(json.dumps(evmapy_config, indent=2))
+
+ def __get_mapping_for_triggers(
+ self,
+ known_button_names: Container[str],
+ absbasex_positive: bool,
+ absbasey_positive: bool,
+ /,
+ ) -> dict[str, str | list[str]]:
+ # maybe this function is more complex if a pad has several hat. never see them.
+ mapping = {
+ 'joystick1right': 'ABS0X:max',
+ 'joystick1left': 'ABS0X:min',
+ 'joystick1down': 'ABS0Y:max',
+ 'joystick1up': 'ABS0Y:min',
+ 'joystick2right': 'ABS1X:max',
+ 'joystick2left': 'ABS1X:min',
+ 'joystick2down': 'ABS1Y:max',
+ 'joystick2up': 'ABS1Y:min',
+ 'joystick1x': ['ABS0X:val', 'ABS0X:min', 'ABS0X:max'],
+ 'joystick1y': ['ABS0Y:val', 'ABS0Y:min', 'ABS0Y:max'],
+ 'joystick2x': ['ABS1X:val', 'ABS1X:min', 'ABS1X:max'],
+ 'joystick2y': ['ABS1Y:val', 'ABS1Y:min', 'ABS1Y:max'],
+ }
+
+ if 'HAT0X:min' in known_button_names:
+ mapping['left'] = 'HAT0X:min'
+ mapping['right'] = 'HAT0X:max'
+ mapping['down'] = 'HAT0Y:max'
+ mapping['up'] = 'HAT0Y:min'
+
+ if 'ABSBASEX:min' in known_button_names:
+ if absbasex_positive:
+ mapping['left'] = 'ABSBASEX:min'
+ mapping['right'] = 'ABSBASEX:max'
+ else:
+ mapping['left'] = 'ABSBASEX:max'
+ mapping['right'] = 'ABSBASEX:min'
+
+ if 'ABSBASEX:min' in known_button_names:
+ if absbasey_positive:
+ mapping['down'] = 'ABSBASEY:max'
+ mapping['up'] = 'ABSBASEY:min'
+ else:
+ mapping['down'] = 'ABSBASEY:min'
+ mapping['up'] = 'ABSBASEY:max'
+
+ return mapping
+
+ # remap evmapy trigger (aka up become HAT0Y:max)
+ def __trigger_mapper(
+ self,
+ trigger: str | list[str],
+ known_button_aliases: Mapping[str, str],
+ known_button_names: Container[str],
+ trigger_mapping: Mapping[str, str | list[str]],
+ /,
+ ) -> str | list[str]:
+ if isinstance(trigger, list):
+ return [
+ cast('str', self.__trigger_mapper_string(x, known_button_aliases, known_button_names, trigger_mapping))
+ for x in trigger
+ ]
+
+ return self.__trigger_mapper_string(trigger, known_button_aliases, known_button_names, trigger_mapping)
+
+ def __trigger_mapper_string(
+ self,
+ trigger: str,
+ known_button_aliases: Mapping[str, str],
+ known_button_names: Container[str],
+ trigger_mapping: Mapping[str, str | list[str]],
+ /,
+ ) -> str | list[str]:
+ if trigger in known_button_aliases:
+ return known_button_aliases[trigger]
+
+ if (mapped := trigger_mapping.get(trigger)) is not None:
+ if isinstance(mapped, list):
+ if all(x in known_button_names for x in mapped):
+ return mapped
+ elif mapped in known_button_names:
+ return trigger_mapping[trigger]
+
+ return trigger # no tranformation
+
+ def __trigger_mapper_mode(self, trigger: str | list[str], /) -> Literal['any'] | None:
+ if isinstance(trigger, list):
+ for x in trigger:
+ mode = self.__trigger_mapper_mode_string(x)
+ if mode is not None:
+ return mode
+ return None
+ return self.__trigger_mapper_mode_string(trigger)
+
+ def __trigger_mapper_mode_string(self, trigger: str, /) -> Literal['any'] | None:
+ if trigger in ['joystick1x', 'joystick1y', 'joystick2x', 'joystick2y']:
+ return 'any'
+ return None
+
+ def __get_gun_trigger(self, trigger: str | list[str], gun: Gun, /) -> str | list[str] | None:
+ if isinstance(trigger, list):
+ for button in trigger:
+ if button not in gun.buttons:
+ return None
+ return trigger
+
+ if trigger not in gun.buttons:
+ return None
+
+ return trigger
+
+ def __get_pad_min_max_axis(self, device_path: str, axis_code: int, /) -> tuple[int, int]:
+ import evdev
+
+ device = evdev.InputDevice(device_path)
+ capabilities = device.capabilities(False)
+
+ for event_type in capabilities:
+ if event_type == 3: # "EV_ABS"
+ for abs_code, val in capabilities[event_type]:
+ if abs_code == axis_code:
+ return val.min, val.max
+
+ return 0, 0 # not found
+
+ def __get_pad_min_max_axis_for_keys(self, min: float, max: float, /) -> tuple[float, float]:
+ val_range = (max - min) / 2 # for each side
+ val_min = min + val_range / 2
+ val_max = max - val_range / 2
+ return val_min, val_max
diff --git a/python-src/batocera-launch/batocera_launch/devices/gun.py b/python-src/batocera-launch/batocera_launch/devices/gun.py
new file mode 100644
index 00000000000..6500a7b7087
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/gun.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+import logging
+import re
+import shutil
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, ClassVar, Final, Protocol, cast
+
+from batocera_common.paths import CONFIGS, SAVES
+
+from ..paths import PRECALIBRATION_DIR
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from ..config.config import SystemConfig
+
+_logger = logging.getLogger(__name__)
+_input_re: Final = re.compile(r'^/dev/input/event([0-9]*)$')
+
+
+def _copy_file(src: Path, dst: Path) -> None:
+ if src.exists() and not dst.exists():
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(src, dst)
+
+
+def _copy_dir(src: Path, dst: Path) -> None:
+ if src.exists() and not dst.exists():
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(src, dst)
+
+
+def _copy_files_in_dir(srcdir: Path, dstdir: Path, startWith: str, endWith: str) -> None:
+ for src in srcdir.iterdir():
+ if src.name.startswith(startWith): # and src.endswith(endswith):
+ _copy_file(src, dstdir / src.name)
+
+
+class LegacyConfig(Protocol):
+ system: str
+
+ @property
+ def emulator(self) -> str: ...
+
+ @property
+ def core(self) -> str: ...
+
+ @property
+ def use_guns(self) -> bool: ...
+
+
+@dataclass(slots=True, kw_only=True)
+class Gun:
+ mouse_buttons_to_code: ClassVar[Mapping[str, int]]
+
+ node: str
+ mouse_index: int
+ needs_cross: bool
+ needs_borders: bool
+ name: str
+ buttons: list[str]
+ button_map: Mapping[str, int] = field(init=False)
+
+ def __post_init__(self) -> None:
+ if not hasattr(Gun, '_mouse_buttons_to_code'):
+ # delay initialization of the map so we don't have
+ # to import evdev until we need it
+ import evdev
+
+ Gun.mouse_buttons_to_code = {
+ 'left': evdev.ecodes.BTN_LEFT,
+ 'right': evdev.ecodes.BTN_RIGHT,
+ 'middle': evdev.ecodes.BTN_MIDDLE,
+ '1': evdev.ecodes.BTN_1,
+ '2': evdev.ecodes.BTN_2,
+ '3': evdev.ecodes.BTN_3,
+ '4': evdev.ecodes.BTN_4,
+ '5': evdev.ecodes.BTN_5,
+ '6': evdev.ecodes.BTN_6,
+ '7': evdev.ecodes.BTN_7,
+ '8': evdev.ecodes.BTN_8,
+ }
+
+ self.button_map = {
+ button: self.mouse_buttons_to_code[button]
+ for button in self.buttons
+ if button in self.mouse_buttons_to_code
+ }
+
+ @staticmethod
+ def get_all() -> GunList:
+ import evdev
+ import pyudev
+
+ guns: GunList = []
+ context = pyudev.Context()
+
+ # guns are mouses, just filter on them
+ mouses = {
+ int(match.group(1)): mouse
+ for mouse in context.list_devices(subsystem='input')
+ if mouse.device_node is not None
+ and (match := _input_re.match(mouse.device_node)) is not None
+ and mouse.properties.get('ID_INPUT_MOUSE') == '1'
+ }
+
+ mouse_code_to_button = {
+ evdev.ecodes.BTN_LEFT: 'left',
+ evdev.ecodes.BTN_RIGHT: 'right',
+ evdev.ecodes.BTN_MIDDLE: 'middle',
+ evdev.ecodes.BTN_1: '1',
+ evdev.ecodes.BTN_2: '2',
+ evdev.ecodes.BTN_3: '3',
+ evdev.ecodes.BTN_4: '4',
+ evdev.ecodes.BTN_5: '5',
+ evdev.ecodes.BTN_6: '6',
+ evdev.ecodes.BTN_7: '7',
+ evdev.ecodes.BTN_8: '8',
+ }
+ mouse_button_codes = set(mouse_code_to_button.keys())
+
+ for mouse_index, (_, mouse) in enumerate(sorted(mouses.items(), key=lambda item: item[0])):
+ _logger.info('found mouse %s at %s with id_mouse=%s', mouse_index, mouse.device_node, mouse_index)
+ if mouse.properties.get('ID_INPUT_GUN') != '1':
+ continue
+
+ device = evdev.InputDevice(cast('str', mouse.device_node))
+ device_codes = set(device.capabilities()[evdev.ecodes.EV_KEY]) & mouse_button_codes
+
+ gun = Gun(
+ node=cast('str', mouse.device_node),
+ # retroarch uses mouse indexes into configuration files using ID_INPUT_MOUSE
+ # (TOUCHPAD are listed after mouses)
+ mouse_index=mouse_index,
+ needs_cross=mouse.properties.get('ID_INPUT_GUN_NEED_CROSS') == '1',
+ needs_borders=mouse.properties.get('ID_INPUT_GUN_NEED_BORDERS') == '1',
+ name=device.name,
+ buttons=[button for code, button in mouse_code_to_button.items() if code in device_codes],
+ )
+ guns.append(gun)
+ _logger.info(
+ 'found gun %s at %s with id_mouse=%s (%s)', len(guns) - 1, mouse.device_node, mouse_index, gun.name
+ )
+
+ if not guns:
+ _logger.info('no gun found')
+
+ return guns
+
+ @classmethod
+ def get_and_precalibrate_all(cls, system_config: SystemConfig | LegacyConfig, rom: Path, /) -> GunList:
+ if not system_config.use_guns:
+ _logger.info('guns disabled.')
+ return []
+
+ dir = PRECALIBRATION_DIR / system_config.system
+
+ # TODO: move preconfiguration code to Emulator subclasses/mixins
+ if dir.exists():
+ emulator = system_config.emulator
+ core = system_config.core
+
+ if system_config.system == 'atomiswave':
+ for suffix in ['nvmem', 'nvmem2']:
+ src = dir / 'reicast' / f'{rom.name}.{suffix}'
+ dst = SAVES / 'atomiswave' / 'reicast' / f'{rom.name}.{suffix}'
+ _copy_file(src, dst)
+
+ elif system_config.system == 'mame':
+ target_dir: str | None = None
+ if emulator == 'mame':
+ target_dir = 'mame'
+ elif emulator == 'libretro':
+ if core == 'mame078plus':
+ target_dir = 'mame/mame2003-plus'
+ elif core == 'mame':
+ target_dir = 'mame/mame'
+
+ if target_dir is not None:
+ src = dir / 'nvram' / rom.stem
+ dst = SAVES / target_dir / 'nvram' / rom.stem
+ _copy_dir(src, dst)
+ srcdir = dir / 'diff'
+ dstdir = SAVES / target_dir / 'diff'
+ _copy_files_in_dir(srcdir, dstdir, rom.stem + '_', '.dif')
+
+ elif system_config.system == 'model2':
+ src = dir / 'NVDATA' / f'{rom.name}.DAT'
+ dst = SAVES / 'model2' / 'NVDATA' / f'{rom.name}.DAT'
+ _copy_file(src, dst)
+
+ elif system_config.system == 'naomi':
+ for suffix in ['nvmem', 'eeprom']:
+ src = dir / 'reicast' / f'{rom.name}.{suffix}'
+ dst = SAVES / 'naomi' / 'reicast' / f'{rom.name}.{suffix}'
+ _copy_file(src, dst)
+
+ elif system_config.system == 'supermodel':
+ src = dir / 'NVDATA' / f'{rom.stem}.nv'
+ dst = SAVES / 'supermodel' / 'NVDATA' / f'{rom.stem}.nv'
+ _copy_file(src, dst)
+
+ elif system_config.system == 'namco2x6': # noqa: SIM102
+ if emulator == 'play':
+ src = dir / 'play' / rom.stem
+ dst = CONFIGS / 'play' / 'Play Data Files' / 'arcadesaves' / f'{rom.stem}.backupram'
+ _copy_file(src, dst)
+
+ return cls.get_all()
+
+
+def guns_need_crosses(guns: Guns, /) -> bool:
+ # no gun, enable the cross for joysticks, mouses...
+ if not guns:
+ return True
+
+ return any(gun.needs_cross for gun in guns)
+
+
+type Guns = Sequence[Gun]
+type GunList = list[Gun]
diff --git a/python-src/batocera-launch/batocera_launch/devices/hotkeygen.py b/python-src/batocera-launch/batocera_launch/devices/hotkeygen.py
new file mode 100644
index 00000000000..ef7038d8f28
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/hotkeygen.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import json
+import logging
+from contextlib import AbstractAsyncContextManager
+from copy import deepcopy
+from dataclasses import InitVar, dataclass, field
+from typing import TYPE_CHECKING, Self
+
+from batocera_common.asyncio import run
+
+if TYPE_CHECKING:
+ from types import TracebackType
+
+ from ..config.config import UIMode
+ from ..types import HotkeysContext
+
+_logger = logging.getLogger(__name__)
+
+
+@dataclass(slots=True)
+class HotkeygenManager(AbstractAsyncContextManager['HotkeygenManager', None]):
+ _context: InitVar[HotkeysContext]
+ exit_only: bool
+ ui_mode: InitVar[UIMode]
+
+ context: HotkeysContext = field(init=False)
+
+ def __post_init__(self, _context: HotkeysContext, ui_mode: UIMode) -> None:
+ self.context = deepcopy(_context)
+
+ # limit hotkeys
+ # there is an option to disable all hotkeys but exit in case the player 1 is a pad with not hotkey specific button
+ if self.exit_only:
+ if 'exit' in self.context['keys']:
+ self.context['keys'] = {'exit': self.context['keys']['exit']}
+ else:
+ # should not happen while exit should always be there
+ self.context['keys'] = {}
+
+ # if uimod is not full (aka kiosk or children mode), remove the menu action
+ if ui_mode != 'Full' and 'menu' in self.context['keys']:
+ del self.context['keys']['menu']
+
+ async def __aenter__(self) -> Self:
+ _logger.debug('hotkeygen: updating context to %s', self.context['name'])
+
+ cmd = ['hotkeygen', '--new-context', self.context['name'], json.dumps(self.context['keys'])]
+
+ if self.exit_only:
+ cmd.append('--disable-common')
+
+ await run(*cmd, capture_output=False)
+
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: TracebackType | None,
+ /,
+ ) -> None:
+ # reset hotkeygen context
+ _logger.debug('hotkeygen: resetting to default context')
+ await run('hotkeygen', '--default-context', capture_output=False)
+
+
+async def reset_mouse() -> None:
+ try:
+ _logger.debug('Triggering mouse reset to primary display')
+ await run('/usr/bin/hotkeygen', '--reset-mouse', capture_output=False)
+ except Exception as e:
+ _logger.warning('Failed to reset mouse: %s', e)
+
+
+def get_hotkeygen_event() -> str | None:
+ import evdev
+
+ for dev in evdev.list_devices():
+ input_device = evdev.InputDevice(dev)
+ if input_device.name == 'batocera hotkeys':
+ return dev
+ return None
diff --git a/python-src/batocera-launch/batocera_launch/devices/input.py b/python-src/batocera-launch/batocera_launch/devices/input.py
new file mode 100644
index 00000000000..2a3a77579b0
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/input.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from collections.abc import Iterator, Mapping
+from dataclasses import dataclass, replace
+from typing import TYPE_CHECKING, Self, TypedDict, Unpack, cast
+
+if TYPE_CHECKING:
+ import xml.etree.ElementTree as ET
+
+
+class _InputChanges(TypedDict, total=False):
+ name: str
+ type: str
+ id: str
+ value: str
+ code: str | None
+
+
+@dataclass(slots=True, kw_only=True)
+class Input:
+ name: str
+ type: str
+ id: str
+ value: str
+ code: str | None = None
+
+ def replace(self, /, **changes: Unpack[_InputChanges]) -> Self:
+ return replace(self, **changes)
+
+ @classmethod
+ def from_element(cls, element: ET.Element, /) -> Self:
+ return cls(
+ name=cast('str', element.get('name')),
+ type=cast('str', element.get('type')),
+ id=cast('str', element.get('id')),
+ value=cast('str', element.get('value')),
+ code=element.get('code'),
+ )
+
+ @classmethod
+ def from_parent_element(cls, parent_element: ET.Element, /) -> Iterator[tuple[str, Self]]:
+ for element in parent_element.iterfind('./input'):
+ input = cls.from_element(element)
+ yield input.name, input
+
+
+type InputMapping = Mapping[str, Input]
+type InputDict = dict[str, Input]
diff --git a/python-src/batocera-launch/batocera_launch/devices/mouse.py b/python-src/batocera-launch/batocera_launch/devices/mouse.py
new file mode 100644
index 00000000000..c8a04bd4f00
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/mouse.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+import logging
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.asyncio import run
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
+_logger: Final = logging.getLogger(__name__)
+
+
+async def set_mouse_mode(mode: bool, /) -> None:
+ _logger.debug('set_mouse_mode(%s)', mode)
+ await run('batocera-mouse', 'show' if mode else 'hide', shell=True)
+
+
+@asynccontextmanager
+async def prepare_mouse(mode: bool, /) -> AsyncGenerator[None]:
+ if mode:
+ await set_mouse_mode(True)
+
+ try:
+ yield
+ finally:
+ if mode:
+ try:
+ await set_mouse_mode(False)
+ except Exception:
+ pass
diff --git a/python-src/batocera-launch/batocera_launch/devices/video.py b/python-src/batocera-launch/batocera_launch/devices/video.py
new file mode 100644
index 00000000000..f59cbe3c29f
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/video.py
@@ -0,0 +1,267 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+import subprocess
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.asyncio import run
+
+from ..exceptions import BatoceraException
+from ..types import Resolution, ScreenInfo
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator, Sequence
+
+ from ..config.config import SystemConfig
+
+_logger: Final = logging.getLogger(__name__)
+_ROTATION_FILE: Final = Path('/var/run/rk-rotation')
+_GLXINFO_BIN: Final = Path('/usr/bin/glxinfo')
+
+
+async def get_current_mode() -> str: # noqa: RET503
+ proc = await run('batocera-resolution', 'currentMode', shell=True)
+
+ for val in proc.stdout.decode().splitlines():
+ return val # return the first line
+
+ if TYPE_CHECKING:
+ raise AssertionError('unreachable')
+
+
+async def min_to_max_resolution() -> None:
+ await run('batocera-resolution', 'minToMaxResolution', shell=True)
+
+
+_max_res_re: Final = re.compile(r'^max-[0-9]*x[0-9]*$')
+
+
+async def mode_exists(video_mode: str) -> bool:
+ # max resolution given
+ if video_mode.startswith('max-'):
+ matches = _max_res_re.match(video_mode)
+ if matches is not None:
+ return True
+
+ # specific resolution given
+ proc = await run('batocera-resolution', 'listModes', shell=True)
+ for line in proc.stdout.decode().splitlines():
+ values = line.split(':')
+ if video_mode == values[0]:
+ return True
+
+ _logger.error('invalid video mode %s', video_mode)
+ return False
+
+
+async def change_mode(video_mode: str) -> None:
+ if await mode_exists(video_mode):
+ cmd = ['batocera-resolution', 'setMode', video_mode]
+ _logger.debug('change_mode(%s): %s', video_mode, cmd)
+ max_tries = 2 # maximum number of tries to set the mode
+ for i in range(1, max_tries + 1):
+ try:
+ proc = await run(*cmd, text=True, check=True)
+ _logger.debug(proc.stdout.strip())
+ return
+ except subprocess.CalledProcessError as e:
+ _logger.error('Error setting video mode: %s', e.stderr)
+ if i == max_tries - 1:
+ raise BatoceraException('Error setting video mode') from e
+ await asyncio.sleep(1)
+
+
+async def get_current_resolution(name: str | None = None) -> Resolution:
+ if name is None:
+ proc = await run('batocera-resolution', 'currentResolution', shell=True, text=True)
+ else:
+ proc = await run('batocera-resolution', f'--screen {name}', 'currentResolution', shell=True, text=True)
+
+ vals = proc.stdout.split('x')
+ return Resolution(width=int(vals[0]), height=int(vals[1]))
+
+
+def is_resolution_reversed() -> bool:
+ return _ROTATION_FILE.exists()
+
+
+async def list_outputs(*, timeout: float | None = None) -> list[str]:
+ try:
+ coro = run('batocera-resolution', 'listOutputs', shell=True, text=True, check=True)
+
+ if timeout is not None:
+ coro = asyncio.wait_for(coro, timeout=timeout)
+
+ proc = await coro
+ except Exception:
+ _logger.exception('Failed to check display count')
+ return []
+
+ return proc.stdout.split()
+
+
+async def get_current_output() -> str:
+ proc = await run('batocera-resolution', 'currentOutput', shell=True, text=True)
+ return proc.stdout.strip()
+
+
+async def supports_system_rotation() -> bool:
+ proc = await run('batocera-resolution', 'supportSystemRotation', shell=True, text=True)
+ return proc.returncode == 0
+
+
+async def get_screens(config: SystemConfig, /) -> list[ScreenInfo]:
+ outputs = await list_outputs()
+ infos: list[ScreenInfo] = []
+
+ # output 1
+ output_1 = await get_current_output()
+ resolution_1 = await get_current_resolution()
+ infos.append(ScreenInfo(output_1, resolution_1, 0, 0))
+
+ # output 2
+ output_2: str | None = None
+
+ # find the configured one
+ if (
+ (output_2_config := config.get_str('videooutput2'))
+ and output_2_config in outputs
+ and output_2_config != output_1
+ ):
+ output_2 = output_2_config
+
+ # find the first one
+ if output_2 is None:
+ for output in outputs:
+ if output != output_1:
+ output_2 = output
+ break
+
+ resolution_2: Resolution | None = None
+ if output_2 is not None:
+ try:
+ resolution_2 = await get_current_resolution(output_2)
+ infos.append(ScreenInfo(output_2, resolution_2, resolution_1.width, 0))
+ except Exception:
+ pass # ignore bad information
+
+ # output 3
+ output_3 = None
+
+ # find the configured one
+ if (
+ (output_3_config := config.get('videooutput3'))
+ and output_3_config in outputs
+ and output_3_config not in (output_1, output_2)
+ ):
+ output_3 = output_3_config
+
+ # find the first one
+ if output_3 is None:
+ for output in outputs:
+ if output not in (output_1, output_2):
+ output_3 = output
+ break
+
+ if output_3 is not None:
+ try:
+ resolution_3 = await get_current_resolution(output_3)
+ infos.append(
+ ScreenInfo(
+ output_3,
+ resolution_3,
+ # if resolution_2 can't be determined, place screen 3 where screen 2 would be
+ resolution_1.width + (0 if resolution_2 is None else resolution_2.width),
+ 0,
+ )
+ )
+ except Exception:
+ pass # ignore bad information
+
+ _logger.debug('Screens: %s', infos)
+
+ return infos
+
+
+def find_screen(screens: Sequence[ScreenInfo], output: str, /) -> ScreenInfo | None:
+ if output == 'backglass' and len(screens) > 1:
+ return screens[1]
+
+ return screens[0]
+
+
+@dataclass(slots=True, frozen=True)
+class GLInfo:
+ vendor: str
+ version: float
+
+
+_OPENGL_VENDOR_RE: Final = re.compile(r'^OpenGL vendor string: (?P.*)$')
+_OPENGL_VERSION_RE: Final = re.compile(r'^OpenGL version string: (?P\d+\.\d+)\b')
+
+
+async def get_gl_info() -> GLInfo:
+ vendor = 'unknown'
+ version = 0
+
+ if _GLXINFO_BIN.exists():
+ try:
+ proc = await run('glxinfo -B', text=True, check=True)
+
+ for line in proc.stdout.splitlines():
+ if match := _OPENGL_VENDOR_RE.match(line):
+ vendor = match.group('vendor').strip().casefold()
+ if match := _OPENGL_VERSION_RE.match(line):
+ version_str = match.group('version').strip()
+ version = float(version_str)
+ except Exception:
+ pass
+
+ return GLInfo(vendor=vendor, version=version)
+
+
+@asynccontextmanager
+async def prepare_resolution(limit_resolution: bool, target_mode: str, /) -> AsyncGenerator[Resolution]:
+ original_mode = await get_current_mode()
+ current_mode = original_mode
+
+ restore_resolution = False
+
+ if limit_resolution:
+ # Limit the display resolution to the maximum supported resolution before starting the game
+ _logger.debug('min_to_max_resolution')
+ _logger.debug('video mode before minmax: %s', original_mode)
+
+ await min_to_max_resolution()
+
+ current_mode = await get_current_mode()
+ if current_mode != original_mode:
+ restore_resolution = True
+
+ _logger.debug('current video mode: %s', current_mode)
+ _logger.debug('target video mode: %s', target_mode)
+
+ if target_mode != 'default' and target_mode != current_mode:
+ await change_mode(target_mode)
+ restore_resolution = True
+
+ game_resolution = await get_current_resolution()
+
+ if is_resolution_reversed():
+ game_resolution = Resolution(width=game_resolution.height, height=game_resolution.width)
+
+ _logger.debug('resolution: %sx%s', game_resolution.width, game_resolution.height)
+
+ try:
+ yield game_resolution
+ finally:
+ if restore_resolution:
+ try:
+ await change_mode(original_mode)
+ except Exception:
+ pass
diff --git a/python-src/batocera-launch/batocera_launch/devices/wheels.py b/python-src/batocera-launch/batocera_launch/devices/wheels.py
new file mode 100644
index 00000000000..7009d2d463a
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/wheels.py
@@ -0,0 +1,385 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import math
+import os
+import re
+from contextlib import asynccontextmanager
+from dataclasses import replace
+from pathlib import Path
+from typing import TYPE_CHECKING, Final, cast
+
+from ..exceptions import BatoceraException
+from .device import DeviceInfoDict, get_device_info, input_event_path_to_id
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator, Iterable
+
+ from ..config.config import SystemConfig
+ from .controller import Controller, ControllerList
+
+_logger = logging.getLogger(__name__)
+
+_WHEEL_MAPPING: Final = {
+ 'wheel': 'joystick1left',
+ 'accelerate': 'r2',
+ 'brake': 'l2',
+ 'downshift': 'pageup',
+ 'upshift': 'pagedown',
+}
+
+# partial mapping between real pads buttons and batocera pads
+_SYSTEM_MAPPING: Final = {
+ 'dreamcast': {
+ 'lt': 'l2',
+ 'rt': 'r2',
+ 'up': 'pageup',
+ 'down': 'pagedown',
+ },
+ 'gamecube': {
+ 'lt': 'l2',
+ 'rt': 'r2',
+ 'a': 'a',
+ 'b': 'b',
+ 'x': 'x',
+ 'y': 'y',
+ },
+ 'saturn': {
+ 'l': 'l2',
+ 'r': 'r2',
+ 'a': 'b',
+ 'b': 'a',
+ 'c': 'pagedown',
+ 'x': 'y',
+ 'y': 'x',
+ 'z': 'pageup',
+ 'start': 'start',
+ },
+ 'n64': {
+ 'l': 'pageup',
+ 'r': 'pagedown',
+ 'a': 'b',
+ 'b': 'y',
+ 'start': 'start',
+ },
+ 'wii': {
+ 'lt': 'l2',
+ 'rt': 'r2',
+ 'a': 'a',
+ 'b': 'b',
+ 'x': 'x',
+ 'y': 'y',
+ },
+ 'wiiu': {
+ 'a': 'a',
+ 'b': 'b',
+ 'x': 'x',
+ 'y': 'y',
+ 'start': 'start',
+ 'select': 'select',
+ },
+ 'psx': {
+ 'cross': 'b',
+ 'square': 'y',
+ 'round': 'a',
+ 'triangle': 'x',
+ 'start': 'start',
+ 'select': 'select',
+ },
+ 'ps2': {
+ 'cross': 'b',
+ 'square': 'y',
+ 'round': 'a',
+ 'triangle': 'x',
+ },
+ 'xbox': {
+ 'lt': 'l2',
+ 'rt': 'r2',
+ 'a': 'b',
+ 'b': 'a',
+ 'x': 'y',
+ 'y': 'x',
+ },
+}
+
+
+@asynccontextmanager
+async def configure_wheels(
+ system_config: SystemConfig, controllers: ControllerList, metadata: dict[str, str], /
+) -> AsyncGenerator[tuple[ControllerList, DeviceInfoDict]]:
+ if not system_config.use_wheels or not controllers:
+ _logger.info('wheels disabled.')
+ yield controllers, {}
+ return
+
+ # search wheels in case use_wheels is enabled for this game
+ # force use_wheels in case es tells it has a wheel
+ devices = await asyncio.to_thread(get_device_info)
+
+ _logger.info('wheels reconfiguration')
+
+ _logger.info('before wheel reconfiguration :')
+ for controller in controllers:
+ _logger.info(
+ ' %s. index:%s dev:%s name:%s',
+ controller.player_number,
+ controller.index,
+ controller.device_path,
+ controller.real_name,
+ )
+
+ # a map of just the items from metadata that start with "wheel_" with "wheel_" removed from the key
+ wheel_metadata = {md_key[6:]: md_value for md_key, md_value in metadata.items() if md_key.startswith('wheel_')}
+
+ # reconfigure wheel buttons
+ for controller in controllers:
+ if controller.device_path in devices and devices[controller.device_path].is_wheel:
+ _logger.info('Wheel reconfiguration for pad %s', controller.real_name)
+ original_inputs = controller.inputs.copy()
+
+ # erase target keys
+ for md_key, md_value in wheel_metadata.items():
+ if (
+ (wheel_key := _WHEEL_MAPPING.get(md_key)) is not None
+ and md_value in _SYSTEM_MAPPING.get(system_config.system, {})
+ and wheel_key in controller.inputs
+ ):
+ del controller.inputs[wheel_key]
+ _logger.info('wheel: erase the key %s', wheel_key)
+
+ # fill with the wanted keys
+ for md_key, md_value in wheel_metadata.items():
+ if (wheel_key := _WHEEL_MAPPING.get(md_key)) is not None and (
+ wanted_key := _SYSTEM_MAPPING.get(system_config.system, {}).get(md_value)
+ ) is not None:
+ if wheel_key in original_inputs:
+ controller.inputs[wanted_key] = original_inputs[wheel_key]
+ controller.inputs[wanted_key].name = wanted_key
+ _logger.info('wheel: fill key %s with %s', wanted_key, wheel_key)
+ else:
+ _logger.info('wheel: unable to replace %s with %s', wanted_key, wheel_key)
+
+ # reconfigure wheel min/max/deadzone
+ procs: list[asyncio.subprocess.Process] = []
+ recompute_sdl_ids = False
+ new_pads: list[str] = []
+ for controller in controllers:
+ if (
+ (device := devices.get(controller.device_path)) is not None
+ and device.is_wheel
+ and device.wheel_rotation is not None
+ ):
+ ra = device.wheel_rotation
+ wanted_ra = ra
+ wanted_deadzone = 0
+ wanted_midzone = 0
+
+ # initialize values with games metadata
+ if 'rotation' in wheel_metadata:
+ wanted_ra = int(wheel_metadata['rotation'])
+ if 'deadzone' in wheel_metadata:
+ wanted_deadzone = int(wheel_metadata['deadzone'])
+ if 'midzone' in wheel_metadata:
+ wanted_midzone = int(wheel_metadata['midzone'])
+
+ # override with user configs
+ if 'wheel_rotation' in system_config:
+ wanted_ra = int(system_config['wheel_rotation'])
+ if 'wheel_deadzone' in system_config:
+ wanted_deadzone = int(system_config['wheel_deadzone'])
+ if 'wheel_midzone' in system_config:
+ wanted_midzone = int(system_config['wheel_midzone'])
+
+ _logger.info(
+ 'wheel rotation angle is %s ; wanted wheel rotation angle is %s ; wanted deadzone is %s ; wanted midzone is %s',
+ ra,
+ wanted_ra,
+ wanted_deadzone,
+ wanted_midzone,
+ )
+
+ # try to write range directly in physical wheel if the driver supports it
+ range_path = Path(device.sysfs_path) / 'range'
+ if os.access(range_path, os.F_OK | os.R_OK | os.W_OK):
+ range_path.write_text(str(wanted_ra))
+ ra = wanted_ra
+
+ # no need new device in some cases
+ if wanted_ra < ra or wanted_deadzone > 0:
+ reconfigure_result = await _reconfigure_angle_rotation(
+ controller, ra, wanted_ra, wanted_deadzone, wanted_midzone
+ )
+ if reconfigure_result is not None:
+ # replace sdl guid by virtualwheel guid for correct sdl mapping
+ controller.guid = '03000000010000000100000001000000'
+ newdev, p = reconfigure_result
+ _logger.info(
+ 'replacing device %s by device %s for player %s',
+ controller.device_path,
+ newdev,
+ controller.player_number,
+ )
+ devices[newdev] = replace(device, event_id=cast('int', input_event_path_to_id(newdev)))
+ controller.physical_device_path = controller.device_path # save the physical device for ffb
+ controller.device_path = newdev # needs to recompute sdl ids
+ recompute_sdl_ids = True
+ new_pads.append(newdev)
+ procs.append(p)
+
+ # recompute sdl ids
+ if recompute_sdl_ids:
+ # build the new joystick list
+ joysticks: dict[int, str] = {device.event_id: node for node, device in devices.items() if device.is_joystick}
+
+ # add the new devices
+ for pad_device_path in new_pads:
+ matches = re.match(r'^/dev/input/event([0-9]*)$', pad_device_path)
+ if matches is not None:
+ joysticks[int(matches.group(1))] = pad_device_path
+
+ # find new sdl numeration
+ joysticks_by_dev: dict[str, int] = {
+ x: current_id for current_id, (_, x) in enumerate(sorted(joysticks.items()))
+ }
+
+ # renumeration
+ for controller in controllers:
+ if (joystick_index := joysticks_by_dev.get(controller.device_path)) is not None:
+ controller.index = joystick_index
+ devices[controller.device_path].joystick_index = joystick_index
+
+ # fill physical_index
+ for controller in controllers:
+ if (
+ controller.physical_device_path is not None
+ and (device := devices.get(controller.physical_device_path)) is not None
+ and device.joystick_index is not None
+ ):
+ controller.physical_index = device.joystick_index # save the physical device for ffb
+
+ # reorder players to priorize wheel pads
+ controllers_new: ControllerList = []
+ nplayer = 1
+ for controller in controllers:
+ if (
+ controller.device_path in devices and devices[controller.device_path].is_wheel
+ ) or controller.device_path in new_pads:
+ controllers_new.append(controller.replace(player_number=nplayer))
+ nplayer += 1
+
+ for controller in controllers:
+ if not (
+ (controller.device_path in devices and devices[controller.device_path].is_wheel)
+ or controller.device_path in new_pads
+ ):
+ controllers_new.append(controller.replace(player_number=nplayer))
+ nplayer += 1
+
+ _logger.info('after wheel reconfiguration :')
+
+ for controller in controllers_new:
+ _logger.info(
+ ' %s. index:%s dev:%s name:%s',
+ controller.player_number,
+ controller.index,
+ controller.device_path,
+ controller.real_name,
+ )
+
+ try:
+ yield controllers_new, {key: device for key, device in devices.items() if device.is_wheel}
+ finally:
+ try:
+ await _reset_controllers(procs)
+ except Exception:
+ _logger.error('hum, unable to reset wheel controllers !')
+ # don't fail
+
+
+async def _reconfigure_angle_rotation(
+ controller: Controller, rotation_angle: int, wanted_rotation_angle: int, wanted_deadzone: int, wanted_midzone: int
+) -> tuple[str, asyncio.subprocess.Process] | None:
+ import evdev
+
+ if 'joystick1left' not in controller.inputs:
+ raise BatoceraException(f'Wheel {controller.real_name} has no joystick1left configured. Strange for a wheel.')
+
+ wheel_axis = int(controller.inputs['joystick1left'].id)
+ input_device = evdev.InputDevice(controller.device_path)
+ caps = input_device.capabilities()
+
+ abs_min = None
+ abs_max = None
+ for v, absinfo in caps[evdev.ecodes.EV_ABS]:
+ if v == wheel_axis:
+ abs_min = absinfo.min
+ abs_max = absinfo.max
+
+ if abs_min is None or abs_max is None:
+ _logger.warning('unable to get min/max of %s', controller.device_path)
+ return None
+
+ total_range = abs_max - abs_min
+ new_min = abs_min
+ new_max = abs_max
+ if wanted_rotation_angle < rotation_angle:
+ new_range = math.floor(total_range * wanted_rotation_angle / rotation_angle)
+ new_min = abs_min + math.ceil((total_range - new_range) / 2)
+ new_max = abs_max - math.floor((total_range - new_range) / 2)
+
+ new_deadzone = 0
+ if wanted_deadzone > 0 and wanted_deadzone > wanted_midzone:
+ new_deadzone = math.floor(total_range * wanted_deadzone / rotation_angle)
+ new_min -= new_deadzone // 2
+ new_max += new_deadzone // 2
+
+ new_midzone = 0
+ if wanted_midzone > 0:
+ new_midzone = math.floor(total_range * wanted_midzone / rotation_angle)
+ new_min += new_midzone // 2
+ new_max -= new_midzone // 2
+
+ cmd = [
+ 'batocera-wheel-calibrator',
+ '-d',
+ controller.device_path,
+ '-a',
+ f'{wheel_axis}',
+ '-m',
+ f'{new_min}',
+ '-M',
+ f'{new_max}',
+ '-z',
+ f'{new_deadzone}',
+ '-c',
+ f'{new_midzone}',
+ ]
+
+ _logger.info(cmd)
+
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+
+ try:
+ assert proc.stdout is not None
+ line = await proc.stdout.readline()
+ new_dev = line.decode().rstrip('\n')
+ except:
+ proc.terminate()
+ await proc.communicate()
+ await proc.wait()
+ raise
+
+ return new_dev, proc
+
+
+async def _reset_controllers(wheel_processes: Iterable[asyncio.subprocess.Process]) -> None:
+ for proc in wheel_processes:
+ _logger.info('killing wheel process %s', proc.pid)
+ proc.terminate()
+ await proc.communicate()
+ await proc.wait()
diff --git a/python-src/batocera-launch/batocera_launch/devices/x11.py b/python-src/batocera-launch/batocera_launch/devices/x11.py
new file mode 100644
index 00000000000..ec376d23de8
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/devices/x11.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+import ctypes.util
+from contextlib import contextmanager
+from dataclasses import dataclass
+from enum import IntFlag
+from typing import TYPE_CHECKING, Any, cast
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
+
+class XSetWindowAttributes(ctypes.Structure):
+ _fields_ = [
+ ('background_pixmap', ctypes.c_ulong),
+ ('background_pixel', ctypes.c_ulong),
+ ('border_pixmap', ctypes.c_ulong),
+ ('border_pixel', ctypes.c_ulong),
+ ('bit_gravity', ctypes.c_int),
+ ('win_gravity', ctypes.c_int),
+ ('backing_store', ctypes.c_int),
+ ('backing_planes', ctypes.c_ulong),
+ ('backing_pixel', ctypes.c_ulong),
+ ('save_under', ctypes.c_int),
+ ('event_mask', ctypes.c_long),
+ ('do_not_propagate_mask', ctypes.c_long),
+ ('override_redirect', ctypes.c_int),
+ ('colormap', ctypes.c_ulong),
+ ('cursor', ctypes.c_ulong),
+ ]
+
+
+_libX11 = ctypes.cdll.LoadLibrary(cast('str', ctypes.util.find_library('X11')))
+_libXfixes = ctypes.cdll.LoadLibrary(cast('str', ctypes.util.find_library('Xfixes')))
+
+# X11 function signatures
+_void_p = ctypes.c_void_p
+_ulong = ctypes.c_ulong
+_int_t = ctypes.c_int
+_uint_t = ctypes.c_uint
+
+_libX11.XOpenDisplay.restype = _void_p
+_libX11.XOpenDisplay.argtypes = [ctypes.c_char_p]
+_libX11.XDefaultScreen.restype = _int_t
+_libX11.XDefaultScreen.argtypes = [_void_p]
+_libX11.XRootWindow.restype = _ulong
+_libX11.XRootWindow.argtypes = [_void_p, _int_t]
+_libX11.XDisplayWidth.restype = _int_t
+_libX11.XDisplayWidth.argtypes = [_void_p, _int_t]
+_libX11.XDisplayHeight.restype = _int_t
+_libX11.XDisplayHeight.argtypes = [_void_p, _int_t]
+_libX11.XCreateWindow.restype = _ulong
+_libX11.XCreateWindow.argtypes = [
+ _void_p,
+ _ulong,
+ _int_t,
+ _int_t,
+ _uint_t,
+ _uint_t,
+ _uint_t,
+ _int_t,
+ _uint_t,
+ _void_p,
+ _ulong,
+ ctypes.POINTER(XSetWindowAttributes),
+]
+_libX11.XMapRaised.argtypes = [_void_p, _ulong]
+_libX11.XFlush.argtypes = [_void_p]
+_libX11.XCloseDisplay.argtypes = [_void_p]
+
+_libXfixes.XFixesCreateRegion.restype = _ulong
+_libXfixes.XFixesCreateRegion.argtypes = [_void_p, _void_p, _int_t]
+_libXfixes.XFixesSetWindowShapeRegion.argtypes = [_void_p, _ulong, _int_t, _int_t, _int_t, _ulong]
+_libXfixes.XFixesDestroyRegion.argtypes = [_void_p, _ulong]
+
+
+class WindowAttributes(IntFlag):
+ back_pixmap = 1 << 0
+ back_pixel = 1 << 1
+ border_pixmap = 1 << 2
+ border_pixel = 1 << 3
+ bit_gravity = 1 << 4
+ win_gravity = 1 << 5
+ backing_store = 1 << 6
+ backing_planes = 1 << 7
+ backing_pixel = 1 << 8
+ ovrride_redirect = 1 << 9
+ save_under = 1 << 10
+ event_mask = 1 << 11
+ dont_propagate = 1 << 12
+ colormap = 1 << 13
+ cursor = 1 << 14
+
+
+class ShapeKind(IntFlag):
+ bounding = 0
+ clip = 1
+ input = 2
+
+
+@cached_dataclass
+class Display:
+ display: ctypes._Pointer[Any]
+
+ @cached_property
+ def screen(self) -> int:
+ return _libX11.XDefaultScreen(self.display) # type: ignore[no-any-return]
+
+ @cached_property
+ def root_window(self) -> int:
+ return _libX11.XRootWindow(self.display, self.screen) # type: ignore[no-any-return]
+
+ @cached_property
+ def screen_width(self) -> int:
+ return _libX11.XDisplayWidth(self.display, self.screen) # type: ignore[no-any-return]
+
+ @cached_property
+ def screen_height(self) -> int:
+ return _libX11.XDisplayHeight(self.display, self.screen) # type: ignore[no-any-return]
+
+ def create_window(
+ self,
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+ /,
+ *,
+ border_width: int = 0,
+ depth: int = 0,
+ window_class: int = 1,
+ attributes_mask: WindowAttributes | None = None,
+ attributes: XSetWindowAttributes | None = None,
+ ) -> Window:
+ window = _libX11.XCreateWindow(
+ self.display,
+ self.root_window,
+ x,
+ y,
+ width,
+ height,
+ border_width,
+ depth,
+ window_class,
+ None,
+ 0 if attributes_mask is None else attributes_mask.real,
+ None if attributes is None else ctypes.byref(attributes),
+ )
+
+ return Window(window=window, display=self)
+
+ def create_empty_region(self) -> int:
+ return _libXfixes.XFixesCreateRegion(self.display, None, 0)
+
+ def destroy_region(self, region: int, /) -> None:
+ _libXfixes.XFixesDestroyRegion(self.display, region)
+
+
+@dataclass(slots=True)
+class Window:
+ window: ctypes._Pointer[Any]
+ display: Display
+
+ def set_window_shape_region(self, kind: ShapeKind, x: int, y: int, region: int, /) -> None:
+ _libXfixes.XFixesSetWindowShapeRegion(self.display.display, self.window, kind.real, x, y, region)
+
+ def map_raised(self) -> None:
+ _libX11.XMapRaised(self.display.display, self.window)
+
+
+@contextmanager
+def open_display() -> Generator[Display]:
+ x11_display = _libX11.XOpenDisplay(None)
+
+ if not x11_display:
+ raise Exception('Cannot open display')
+
+ try:
+ yield Display(display=x11_display)
+ finally:
+ _libX11.XFlush(x11_display)
diff --git a/python-src/batocera-launch/batocera_launch/draw/__init__.py b/python-src/batocera-launch/batocera_launch/draw/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/python-src/batocera-launch/batocera_launch/draw/bezel.py b/python-src/batocera-launch/batocera_launch/draw/bezel.py
new file mode 100644
index 00000000000..a92c2aca5d5
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/draw/bezel.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.paths import LOGS
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+ from pathlib import Path
+
+ from ..types import Resolution
+
+_logger = logging.getLogger(__name__)
+_LOG_PATH: Final = LOGS / 'bezelOverlay.log'
+
+
+@asynccontextmanager
+async def bezel_overlay(bezel: Path | None, resolution: Resolution, /) -> AsyncGenerator[None]:
+ overlay_proc: asyncio.subprocess.Process | None = None
+
+ if bezel is not None and bezel.exists():
+ _logger.info('Spawning standalone bezel overlay process for: %s', bezel)
+
+ try:
+ overlay_env = dict(os.environ)
+ overlay_proc = await asyncio.create_subprocess_exec(
+ '/usr/bin/batocera-bezel-overlay',
+ '--log-level=debug',
+ f'--log-file={_LOG_PATH}',
+ bezel,
+ str(resolution.width),
+ str(resolution.height),
+ env=overlay_env,
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.DEVNULL,
+ )
+
+ # Give the window a moment to initialize and check if it crashed
+ await asyncio.sleep(0.3)
+
+ if overlay_proc.returncode is not None:
+ _logger.error(
+ 'Bezel overlay process exited immediately with status: %s. Check %s for details.',
+ overlay_proc.returncode,
+ _LOG_PATH,
+ )
+ overlay_proc = None
+ except Exception:
+ _logger.exception('Could not initialize standalone bezel overlay')
+
+ try:
+ yield
+ finally:
+ if overlay_proc is not None and overlay_proc.returncode is None:
+ _logger.info('Terminating standalone bezel overlay process')
+
+ overlay_proc.terminate()
+
+ try:
+ await asyncio.wait_for(overlay_proc.wait(), timeout=2)
+ except TimeoutError:
+ overlay_proc.kill()
diff --git a/python-src/batocera-launch/batocera_launch/draw/gun_borders.py b/python-src/batocera-launch/batocera_launch/draw/gun_borders.py
new file mode 100644
index 00000000000..34b8c25adb2
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/draw/gun_borders.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import math
+from typing import TYPE_CHECKING, Final
+
+from ..devices.x11 import Display, ShapeKind, WindowAttributes, XSetWindowAttributes
+from .pil import get_image_size
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_BORDER_COLOR_VALUES: Final = {
+ 'white': 0xFFFFFF,
+ 'red': 0xFF0000,
+ 'green': 0x00FF00,
+ 'blue': 0x0000FF,
+}
+
+_BORDER_COLOR_RGB: Final = {
+ 'white': '#ffffff',
+ 'red': '#ff0000',
+ 'green': '#00ff00',
+ 'blue': '#0000ff',
+ 'black': '#000000',
+}
+
+_BORDER_PRESETS: Final = {
+ 'thin': (1, 0),
+ 'medium': (2, 0),
+ 'big': (2, 1),
+}
+
+
+def create_gun_border_image(
+ input_png: Path,
+ output_path: Path,
+ border_size: str,
+ border_ratio: str | None,
+ /,
+ *,
+ inner_color: str = 'white',
+ outer_color: str = 'black',
+) -> None:
+ # good default border that works in most circumstances is:
+ #
+ # 2% of the screen width in white. Surrounded by 3% screen width of
+ # black. I have attached an example. The black helps the lightgun detect
+ # the border against a bright background behind the tv.
+ #
+ # The ideal solution is to draw the games inside the border rather than
+ # overlap. Then you can see the whole game. The lightgun thinks that the
+ # outer edge of the border is the edge of the game screen. So you have to
+ # make some adjustments in the lightgun settings to keep it aligned. This
+ # is why normally the border overlaps as it means that people do not need
+ # to calculate an adjustment and is therefore easier.
+ #
+ # If all the games are drawn with the border this way then the settings
+ # are static and the adjustment only needs to be calculated once.
+
+ from PIL import Image, ImageDraw
+
+ w, h = get_image_size(input_png)
+ inner_percent, outer_percent = _BORDER_PRESETS.get(border_size, (0, 0))
+
+ # Calculate new width for 4:3 aspect ratio if a widescreen resolution
+ if abs(w / h - 4 / 3) < 0.01:
+ new_w = w
+ elif border_ratio == '4:3':
+ new_w = int((4 / 3) * h)
+ else:
+ new_w = w
+
+ # Calculate offset for centering the border image
+ offset_x = (w - new_w) // 2
+
+ # outer border
+ outer_border_size = w * outer_percent // 100 # use only h to have homogen border size
+ if outer_border_size < 1: # minimal size
+ outer_border_size = 0
+ outer_shapes = [
+ [(offset_x, 0), (offset_x + new_w, outer_border_size)],
+ [(offset_x + new_w - outer_border_size, 0), (offset_x + new_w, h)],
+ [(offset_x, h - outer_border_size), (offset_x + new_w, h)],
+ [(offset_x, 0), (offset_x + outer_border_size, h)],
+ ]
+
+ # inner border
+ inner_border_size = w * inner_percent // 100 # use only h to have homogen border size
+ if inner_border_size < 1: # minimal size
+ inner_border_size = 1
+ inner_shapes = [
+ [
+ (offset_x + outer_border_size, outer_border_size),
+ (offset_x + new_w - outer_border_size, outer_border_size + inner_border_size),
+ ],
+ [
+ (offset_x + new_w - outer_border_size - inner_border_size, outer_border_size),
+ (offset_x + new_w - outer_border_size, h - outer_border_size),
+ ],
+ [
+ (offset_x + outer_border_size, h - outer_border_size - inner_border_size),
+ (offset_x + new_w - outer_border_size, h - outer_border_size),
+ ],
+ [
+ (offset_x + outer_border_size, outer_border_size),
+ (offset_x + outer_border_size + inner_border_size, h - outer_border_size),
+ ],
+ ]
+
+ back = Image.open(input_png)
+
+ new_image = Image.new('RGBA', (w, h), (0, 0, 0, 255))
+ new_image.paste(back, (0, 0, w, h))
+
+ new_image_draw = ImageDraw.Draw(new_image)
+
+ for shape in outer_shapes:
+ new_image_draw.rectangle(shape, fill=_BORDER_COLOR_RGB.get(outer_color, _BORDER_COLOR_RGB['black']))
+
+ for shape in inner_shapes:
+ new_image_draw.rectangle(shape, fill=_BORDER_COLOR_RGB.get(inner_color, _BORDER_COLOR_RGB['white']))
+
+ new_image.save(output_path, mode='RGBA', format='PNG')
+
+
+def _create_click_through_rectangle(display: Display, x: int, y: int, width: int, height: int, color: int, /) -> None:
+ if width <= 0 or height <= 0:
+ return
+
+ attributes = XSetWindowAttributes()
+ attributes.background_pixel = color
+ attributes.override_redirect = 1
+
+ window = display.create_window(
+ x,
+ y,
+ width,
+ height,
+ attributes_mask=WindowAttributes.back_pixel | WindowAttributes.ovrride_redirect,
+ attributes=attributes,
+ )
+ empty_region = display.create_empty_region()
+ window.set_window_shape_region(ShapeKind.input, 0, 0, empty_region)
+ display.destroy_region(empty_region)
+ window.map_raised()
+
+
+def _draw_border(
+ display: Display, offset_x: int, offset_y: int, width: int, height: int, thickness: int, color: int, /
+) -> None:
+ if thickness <= 0:
+ return
+
+ _create_click_through_rectangle(display, offset_x, offset_y, width, thickness, color)
+ _create_click_through_rectangle(display, offset_x, offset_y + height - thickness, width, thickness, color)
+ _create_click_through_rectangle(display, offset_x, offset_y + thickness, thickness, height - 2 * thickness, color)
+ _create_click_through_rectangle(
+ display,
+ offset_x + width - thickness,
+ offset_y + thickness,
+ thickness,
+ height - 2 * thickness,
+ color,
+ )
+
+
+def draw_gun_borders(border_size: str, border_color: str, border_ratio: str | None, /) -> None:
+ from ..devices.x11 import open_display
+
+ inner_percent, outer_percent = _BORDER_PRESETS.get(border_size, (2, 0))
+
+ with open_display() as display:
+ screen_width = display.screen_width
+ screen_height = display.screen_height
+
+ region_width = screen_width
+ region_offset_x = 0
+
+ if border_ratio == '4:3':
+ region_width = min(int(screen_height / 3 * 4), screen_width)
+ region_offset_x = (screen_width - region_width) // 2
+
+ outer_thickness = math.ceil(screen_width * outer_percent / 100)
+ inner_thickness = max(math.ceil(screen_width * inner_percent / 100), 1)
+
+ # outer
+ _draw_border(display, region_offset_x, 0, region_width, screen_height, outer_thickness, 0x000000)
+
+ # inner
+ _draw_border(
+ display,
+ region_offset_x + outer_thickness,
+ outer_thickness,
+ region_width - 2 * outer_thickness,
+ screen_height - 2 * outer_thickness,
+ inner_thickness,
+ _BORDER_COLOR_VALUES.get(border_color, 0xFFFFFF),
+ )
+
+
+if __name__ == '__main__':
+ import signal
+ import sys
+
+ border_size = 'medium'
+ border_color = 'white'
+ border_ratio = None
+
+ args = sys.argv[1:]
+ i = 0
+ while i < len(args):
+ if args[i] in ('-s', '--size') and i + 1 < len(args):
+ border_size = args[i + 1]
+ i += 2
+ elif args[i] in ('-c', '--color') and i + 1 < len(args):
+ border_color = args[i + 1]
+ i += 2
+ elif args[i] in ('-r', '--ratio') and i + 1 < len(args):
+ border_ratio = args[i + 1]
+ i += 2
+ else:
+ i += 1
+
+ draw_gun_borders(border_size, border_color, border_ratio)
+
+ while True:
+ signal.pause()
diff --git a/python-src/batocera-launch/batocera_launch/draw/gun_help.py b/python-src/batocera-launch/batocera_launch/draw/gun_help.py
new file mode 100644
index 00000000000..294165f3c0d
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/draw/gun_help.py
@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+from pathlib import Path
+from typing import TYPE_CHECKING, Final, NotRequired, TypedDict, cast
+
+from PIL import Image, ImageDraw, ImageFont
+
+from batocera_common.paths import BATOCERA_SHARE_DIR
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+ from ..devices.gun import Guns
+ from ..types import Resolution
+
+_logger = logging.getLogger(__name__)
+
+_FONT_PATH: Final = Path('/usr/share/fonts/dejavu/DejaVuSans.ttf')
+_GUN_HELP_DIR: Final = Path('/var/run/batocera-overlays')
+_GUN_HELP_CACHE_PATH: Final = _GUN_HELP_DIR / 'gun_help_default.png'
+_GUN_HELP_PATH: Final = _GUN_HELP_DIR / 'gun_help.png'
+_GUNS_OVERLAYS_DIR: Final = BATOCERA_SHARE_DIR / 'guns-overlays'
+_IMG_RATIO: Final = 0.5 # ratio of the screen height
+
+_DEFAULT_REPLACEMENTS: Final = {
+ 'TRIGGER': 'TRIGGER',
+ 'ACTION': 'ACTION',
+ 'START': 'START',
+ 'SELECT': 'SELECT',
+ 'SUB1': 'SUB1',
+ 'SUB2': 'SUB2',
+ 'SUB3': 'SUB3',
+ 'UP': 'UP',
+ 'DOWN': 'DOWN',
+ 'LEFT': 'LEFT',
+ 'RIGHT': 'RIGHT',
+}
+
+
+class _GunInfosTextDict(TypedDict):
+ value: str
+ x: float
+ y: float
+ line_color: str
+ line: list[float]
+ align: NotRequired[str]
+ font_size_per_height: NotRequired[float]
+
+
+class _GunInfosDict(TypedDict):
+ texts: NotRequired[list[_GunInfosTextDict]]
+ font_size_per_height: NotRequired[float]
+ color: NotRequired[str]
+
+
+def _png_to_png_with_texts(
+ input_png_path: Path,
+ output_png_path: Path,
+ data: _GunInfosDict,
+ /,
+ *,
+ width: int | None = None,
+ height: int | None = None,
+) -> None:
+ img_big = Image.open(input_png_path)
+ ratio = img_big.width / img_big.height
+
+ if width is None and height is None:
+ raise ValueError('width or height must be provided')
+
+ img_width: int = 0
+ img_height: int = 0
+
+ if width is None and height is not None:
+ img_height = height
+ img_width = int(height * ratio)
+
+ if width is not None and height is None:
+ img_width = width
+ img_height = int(width * ratio)
+
+ img = img_big.resize((img_width, img_height)) # pyright: ignore[reportUnknownMemberType]
+ draw = ImageDraw.Draw(img)
+
+ # font
+ font: dict[float, ImageFont.FreeTypeFont] = {}
+ if 'font_size_per_height' in data:
+ font_size = int(data['font_size_per_height'] * img_height)
+ font[font_size] = ImageFont.truetype(_FONT_PATH, font_size)
+
+ # lines
+ if 'texts' in data:
+ for text in data['texts']:
+ if 'value' in text and text['value'] != '':
+ line_color = 'black'
+ line_size = 2
+ if 'line_color' in text:
+ line_color = text['line_color']
+ if 'line_size' in text:
+ line_size = text['line_size']
+ if 'line' in text:
+ points: list[tuple[float, float]] = []
+ for i, v in enumerate(text['line']):
+ if i % 2 == 1:
+ points.append((text['line'][i - 1] * img_width, v * img_height))
+ draw.line(points, fill=line_color, width=line_size)
+
+ # texts
+ if 'texts' in data and 'font_size_per_height' in data:
+ for text in data['texts']:
+ if 'x' in text and 'y' in text and 'value' in text:
+ # x, y
+ x = round(text['x'] * img_width)
+ y = round(text['y'] * img_height)
+
+ # color
+ color = 'black'
+ if 'color' in data:
+ color = data['color']
+ if 'color' in text:
+ color = text['color']
+
+ # font
+ font_size = int(data['font_size_per_height'] * img_height)
+ if 'font_size_per_height' in text:
+ font_size = int(text['font_size_per_height'] * img_height)
+ if font_size not in font:
+ font[font_size] = ImageFont.truetype(_FONT_PATH, font_size)
+
+ # alignment
+ text_width = draw.textlength(text['value'], font[font_size])
+ align = 'left'
+ if 'align' in text:
+ align = text['align']
+ if align == 'center':
+ x = x - int(text_width / 2)
+ if align == 'right':
+ x = x - text_width
+ draw.text((x, y), text['value'], fill=color, font=font[font_size])
+
+ # save
+ img.save(output_png_path, 'PNG')
+
+
+def _gun_help_replace(text: str, replacements: Mapping[str, str], /) -> str:
+ result = text
+
+ for replacement, value in replacements.items():
+ result = result.replace(f'<{replacement}>', value)
+
+ return result
+
+
+def generate_gun_help(use_guns: bool, guns: Guns, metadata: Mapping[str, str], resolution: Resolution, /) -> None:
+ _GUN_HELP_DIR.mkdir(parents=True, exist_ok=True)
+
+ customize_texts = any(key.startswith('gun_') for key in metadata)
+
+ # if we use the image without any customization, copy the backup
+ # cache file to the destination
+ if (use_guns or guns) and not customize_texts and _GUN_HELP_CACHE_PATH.exists():
+ shutil.copyfile(_GUN_HELP_CACHE_PATH, _GUN_HELP_PATH)
+ _logger.info('using cache image: %s', _GUN_HELP_CACHE_PATH)
+ return
+
+ # remove any existing file
+ if _GUN_HELP_PATH.exists():
+ _GUN_HELP_PATH.unlink()
+
+ # don't enable if not a gun game or no gun
+ if not (use_guns and guns):
+ _logger.info('not generating gun help image')
+ return
+
+ _logger.info('generating gun help image')
+
+ # take the first gun
+ gun_name = guns[0].name
+ gun_overlay_png = _GUNS_OVERLAYS_DIR / f'{gun_name}.png'
+
+ if not gun_overlay_png.exists():
+ _logger.info("image doesn't exist: %s", gun_overlay_png)
+ return
+
+ gun_overlay_info = gun_overlay_png.with_suffix('.infos')
+
+ # try to open the help texts
+ data: _GunInfosDict = {}
+ if gun_overlay_info.exists():
+ with gun_overlay_info.open(encoding='utf-8') as file:
+ data = cast('_GunInfosDict', json.load(file))
+
+ # replace data in texts
+ if 'texts' in data:
+ # if we customize text, we reset replacements by the ones in the metadata (and
+ # an empty string if it doesn't exist in the metadata)
+ replacements = (
+ {key: metadata.get(f'gun_{key.lower()}', '') for key in _DEFAULT_REPLACEMENTS}
+ if customize_texts
+ else _DEFAULT_REPLACEMENTS
+ )
+
+ for text in data['texts']:
+ text['value'] = _gun_help_replace(text['value'], replacements)
+
+ _logger.info('generating image: %s', _GUN_HELP_PATH)
+ _png_to_png_with_texts(gun_overlay_png, _GUN_HELP_PATH, data, height=int(resolution.height * _IMG_RATIO))
+
+ if not customize_texts:
+ # cache the file since it hasn't been customized
+ shutil.copyfile(_GUN_HELP_PATH, _GUN_HELP_CACHE_PATH)
+ _logger.info('caching file to: %s', _GUN_HELP_CACHE_PATH)
diff --git a/python-src/batocera-launch/batocera_launch/draw/pil.py b/python-src/batocera-launch/batocera_launch/draw/pil.py
new file mode 100644
index 00000000000..1b89a28d286
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/draw/pil.py
@@ -0,0 +1,241 @@
+from __future__ import annotations
+
+import logging
+import struct
+from pathlib import Path
+from typing import TYPE_CHECKING, cast
+
+from PIL import Image, ImageDraw, ImageOps
+
+from batocera_common.paths import BATOCERA_SHARE_DIR
+
+from ..exceptions import BatoceraException
+
+if TYPE_CHECKING:
+ from _typeshed import StrPath
+
+ from PIL.ImageFile import ImageFile
+ from qrcode.image.pil import PilImage
+
+ from ..config.config import SystemConfig
+
+_logger = logging.getLogger(__name__)
+
+
+# Much faster than PIL Image.size
+def get_image_size(image_file: StrPath, /) -> tuple[int, int]:
+ image_file = Path(image_file)
+
+ if not image_file.exists():
+ return -1, -1
+
+ with image_file.open('rb') as fhandle:
+ head = fhandle.read(32)
+
+ if len(head) != 32:
+ # corrupted header, or not a PNG
+ return -1, -1
+
+ if struct.unpack('>i', head[4:8])[0] != 0x0D0A1A0A:
+ # Not a PNG
+ return -1, -1
+
+ return struct.unpack('>ii', head[16:24]) # image width, height
+
+
+def create_transparent_image(path: StrPath, width: int, height: int, /) -> None:
+ path = Path(path)
+ image = Image.new('RGBA', (width, height), (0, 0, 0, 0))
+ ImageDraw.Draw(image)
+ image.save(path, format='PNG', mode='RGBA')
+
+
+def resize_image(
+ input_path: StrPath, output_path: StrPath, width: int, height: int, /, *, stretch: bool = False
+) -> None:
+ input_image = Image.open(input_path)
+ fillcolor = 'black'
+
+ if input_image.mode != 'RGBA':
+ _alpha_paste(input_path, output_path, fillcolor, (width, height), stretch)
+ else:
+ output_image = input_image.resize((width, height), Image.Resampling.BICUBIC) # pyright: ignore[reportUnknownMemberType]
+ output_image.save(output_path, mode='RGBA', format='PNG')
+
+
+def pad_image(
+ input_png: str | Path,
+ output_png: str | Path,
+ width: int,
+ height: int,
+ /,
+ *,
+ stretch: bool = False,
+) -> None:
+ input_image = Image.open(input_png)
+ fill_color = 'black'
+
+ _logger.debug('Padding bezel: image mode %s', input_image.mode)
+
+ if input_image.mode != 'RGBA':
+ _alpha_paste(input_png, output_png, fill_color, (width, height), stretch)
+ else:
+ if stretch:
+ output_image = ImageOps.fit(input_image, (width, height))
+ else:
+ output_image = ImageOps.pad(input_image, (width, height), color=fill_color, centering=(0.5, 0.5))
+
+ output_image.save(output_png, mode='RGBA', format='PNG')
+
+
+def add_qr_code(input_png: StrPath, output_png: StrPath, code: str, corner: str, /) -> None:
+ import qrcode
+
+ url = f'https://retroachievements.org/game/{code}'
+
+ box_size = 3
+ border_size = 2
+
+ qr = qrcode.QRCode(version=1, box_size=box_size, border=border_size)
+ qr.add_data(url)
+ qr.make()
+
+ qr_image = cast('PilImage', qr.make_image(back_color=(120, 120, 120)))
+
+ x = 29 * box_size + border_size * box_size * 2
+
+ qr_image = cast('Image.Image', qr_image.convert('RGBA'))
+
+ new_bezel = Image.open(input_png)
+ new_bezel = new_bezel.convert('RGBA')
+
+ width, height = get_image_size(input_png)
+
+ if corner.upper() == 'NW':
+ new_bezel.paste(qr_image, (0, 0, x, x))
+ elif corner.upper() == 'SE':
+ new_bezel.paste(qr_image, (width - x, height - x, width, height))
+ elif corner.upper() == 'SW':
+ new_bezel.paste(qr_image, (0, height - x, x, height))
+ else: # default = NE
+ new_bezel.paste(qr_image, (width - x, 0, width, x))
+
+ new_bezel.save(output_png)
+
+
+def add_tattoo_image(input_path: StrPath, output_path: StrPath, config: SystemConfig, /) -> None:
+ tattoo_file: ImageFile | None = None
+ bezel_tattoo = config.get_str('bezel.tattoo')
+
+ if bezel_tattoo == 'system':
+ tattoo_path = BATOCERA_SHARE_DIR / 'controller-overlays' / f'{config.system}.png'
+ try:
+ if not tattoo_path.exists():
+ tattoo_path = BATOCERA_SHARE_DIR / 'controller-overlays' / 'generic.png'
+ tattoo_file = Image.open(tattoo_path)
+ except Exception:
+ _logger.error('Error opening controller overlay: %s', tattoo_path)
+ elif (
+ bezel_tattoo == 'custom'
+ and (bezel_tattoo_file := config.get('bezel.tattoo_file')) is not None
+ and (tattoo_path := Path(bezel_tattoo_file)).exists()
+ ):
+ try:
+ tattoo_file = Image.open(tattoo_path)
+ except Exception:
+ _logger.error('Error opening custom file: %s', tattoo_path)
+ else:
+ tattoo_path = BATOCERA_SHARE_DIR / 'controller-overlays' / 'generic.png'
+ try:
+ tattoo_file = Image.open(tattoo_path)
+ except Exception:
+ _logger.error('Error opening custom file: %s', tattoo_path)
+
+ if tattoo_file is None:
+ raise BatoceraException(f'Tattoo image could not be opened: {tattoo_path}')
+
+ # Open the existing bezel...
+ back_image = Image.open(input_path)
+ # Convert it otherwise it implodes later on...
+ back_image = back_image.convert('RGBA')
+
+ tattoo_image = tattoo_file.convert('RGBA')
+
+ # Quickly grab the sizes.
+ width, height = get_image_size(input_path)
+ tattoo_width, tattoo_height = get_image_size(tattoo_path)
+
+ if not config.get_bool('bezel.resize_tattoo', True):
+ # Maintain the image's original size.
+ # Failsafe for if the image is too large.
+ if tattoo_width > width or tattoo_height > height:
+ # Limit width to that of the bezel and crop the rest.
+ percent = float(width / tattoo_width)
+ tattoo_height = int(float(tattoo_height) * percent)
+ # Resize the tattoo to the calculated size.
+ tattoo_image = tattoo_image.resize((width, tattoo_height), Image.Resampling.BICUBIC) # pyright: ignore[reportUnknownMemberType]
+ else:
+ # Resize to be slightly smaller than the bezel's column.
+ tattoo_width_tmp = int((225 / 1920) * width)
+ percent = float(tattoo_width_tmp / tattoo_width)
+ tattoo_height = int(float(tattoo_height) * percent)
+ tattoo_image = tattoo_image.resize((tattoo_width_tmp, tattoo_height), Image.Resampling.BICUBIC) # pyright: ignore[reportUnknownMemberType]
+ tattoo_width = tattoo_width_tmp
+
+ # Create a new blank canvas that is the same size as the bezel for later compositing (they are required to be the same size).
+ tattoo_canvas = Image.new('RGBA', back_image.size)
+ # Margin for the tattoo
+ margin = int((20 / 1080) * height)
+
+ corner = config.get('bezel.tattoo_corner', 'NW')
+ if corner.upper() == 'NE':
+ tattoo_canvas.paste(tattoo_image, (width - tattoo_width, margin)) # 20 pixels vertical margins (on 1080p)
+ elif corner.upper() == 'SE':
+ tattoo_canvas.paste(tattoo_image, (width - tattoo_width, height - tattoo_height - margin))
+ elif corner.upper() == 'SW':
+ tattoo_canvas.paste(tattoo_image, (0, height - tattoo_height - margin))
+ else: # default = NW
+ tattoo_canvas.paste(tattoo_image, (0, margin))
+
+ back_image = Image.alpha_composite(back_image, tattoo_canvas)
+
+ new_image = Image.new('RGBA', (width, height), (0, 0, 0, 255))
+ new_image.paste(back_image, (0, 0, width, height))
+ new_image.save(output_path, mode='RGBA', format='PNG')
+
+
+def _alpha_paste(
+ input_png: StrPath, output_png: StrPath, fill_color: str, screen_size: tuple[int, int], bezel_stretch: bool, /
+) -> None:
+ input_image = Image.open(input_png)
+
+ # TheBezelProject have Palette + alpha, not RGBA. PIL can't convert from P+A to RGBA.
+ # Even if it can load P+A, it can't save P+A as PNG. So we have to recreate a new image to adapt it.
+ if 'transparency' not in input_image.info:
+ raise BatoceraException('No transparent pixels in the bezel image')
+
+ screen_width, screen_height = screen_size
+
+ alpha = input_image.split()[-1] # alpha from original palette + alpha
+ image_width, image_height = get_image_size(input_png)
+ image_ratio = float(image_width) / float(image_height)
+ screen_ratio = float(screen_width) / float(screen_height)
+
+ if image_ratio - screen_ratio > 0.01:
+ # cut off bezel sides for 16:10 screens
+ new_x = int(image_width * screen_ratio / image_ratio)
+ delta = int(image_width - new_x)
+ border_x = delta // 2
+ image_width = new_x
+ alpha_new = alpha.crop((border_x, 0, new_x + border_x, image_height))
+ alpha = alpha_new
+
+ new_image = Image.new('RGBA', (image_width, image_height), (0, 0, 0, 255))
+ new_image.paste(alpha, (0, 0, image_width, image_height))
+
+ if bezel_stretch:
+ output_image = ImageOps.fit(new_image, screen_size)
+ else:
+ output_image = ImageOps.pad(new_image, screen_size, color=fill_color, centering=(0.5, 0.5))
+
+ output_image.save(output_png, mode='RGBA', format='PNG')
diff --git a/python-src/batocera-launch/batocera_launch/emulator.py b/python-src/batocera-launch/batocera_launch/emulator.py
new file mode 100644
index 00000000000..20b2feb9a57
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulator.py
@@ -0,0 +1,758 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import sys
+import xml.etree.ElementTree as ET
+from abc import ABC, abstractmethod
+from contextlib import (
+ AbstractAsyncContextManager,
+ AbstractContextManager,
+ AsyncExitStack,
+ chdir,
+)
+from dataclasses import field
+from pathlib import Path
+from typing import TYPE_CHECKING, ClassVar, Final, Self, cast
+
+from batocera_common.asyncio import group_tasks, parallel
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.math import clamp
+from batocera_common.paths import BIOS, CONFIGS, ROMS, SAVES
+
+from .asyncio import script_caller
+from .config.config import SystemConfig
+from .config.metadata import get_games_meta_data
+from .devices.controller import Controller, generate_sdl_game_controller_config
+from .devices.evmapy import EvmapyManager
+from .devices.gun import Gun, guns_need_crosses
+from .devices.hotkeygen import HotkeygenManager, reset_mouse as _hotkeygen_reset_mouse
+from .devices.mouse import prepare_mouse
+from .devices.video import list_outputs, prepare_resolution
+from .devices.wheels import configure_wheels
+from .draw.bezel import bezel_overlay
+from .draw.gun_borders import create_gun_border_image
+from .draw.gun_help import generate_gun_help
+from .draw.pil import (
+ add_qr_code,
+ add_tattoo_image,
+ create_transparent_image,
+ get_image_size,
+ resize_image,
+)
+from .exceptions import UnknownEmulator
+from .paths import ES_GAMES_METADATA, ES_GUNS_ART_METADATA, SYSTEM_DECORATIONS, USER_DECORATIONS
+from .rom import Rom
+from .types import BezelFiles, BezelInfo
+
+if TYPE_CHECKING:
+ from collections.abc import Container, Iterator, Mapping
+ from types import TracebackType
+
+ from .cli.arguments import Arguments
+ from .command import Command
+ from .config.config import Config
+ from .config.es_settings import ESSettings
+ from .devices.controller import Controllers
+ from .devices.device import DeviceInfoMapping
+ from .devices.gun import Guns
+ from .profiler import Profiler
+ from .types import HotkeysContext, Resolution
+
+_logger: Final = logging.getLogger(__name__)
+
+
+@cached_dataclass
+class Emulator(AbstractAsyncContextManager['Emulator', bool | None], ABC):
+ needs_sdl_game_controller_config: ClassVar[bool] = False
+ needs_sdl_controller_db: ClassVar[bool] = False
+ sdl_controller_db_path: ClassVar[Path] = Path('/tmp/gamecontrollerdb.txt')
+ sdl_game_controller_config_ignore_buttons: ClassVar[Container[str] | None] = None
+
+ config: SystemConfig
+ profiler: Profiler
+
+ # These are set in __post_init__
+ system: str = field(init=False)
+ fancy_system_name: str | None = field(init=False)
+ game_info_path: Path = field(init=False)
+
+ # These are set in __aenter__
+ rom: Rom = field(init=False, default=cast('Rom', None))
+ metadata: dict[str, str] = field(init=False, default=cast('dict[str, str]', None))
+ controllers: Controllers = field(init=False, default=cast('Controllers', None))
+ guns: Guns = field(init=False, default=cast('Guns', None))
+ wheels: DeviceInfoMapping = field(init=False, default=cast('DeviceInfoMapping', None))
+ resolution: Resolution = field(init=False, default=cast('Resolution', None))
+
+ __stack: AsyncExitStack = field(init=False, default_factory=AsyncExitStack)
+
+ def __post_init__(self) -> None:
+ self.system = self.config.system
+ self.fancy_system_name = self.config.cli_args.systemname
+ self.game_info_path = self.config.cli_args.gameinfoxml
+
+ async def __aenter__(self) -> Self:
+ await self.__stack.__aenter__()
+
+ try:
+ self.rom = await self.__stack.enter_async_context(
+ Rom.prepare(
+ self.config.rom,
+ writable_dir=(SAVES / self.config.system / self.config.rom.stem) if self.needs_overlayfs else None,
+ )
+ )
+
+ # Emulator subclasses may use the rom to calculate the save directory, so wait until this
+ # point to create it
+ self.saves_dir.mkdir(parents=True, exist_ok=True)
+
+ controllers, self.guns, self.metadata = await group_tasks(
+ asyncio.to_thread(Controller.load_for_players, self.config.cli_args.players),
+ asyncio.to_thread(Gun.get_and_precalibrate_all, self.config, self.rom),
+ asyncio.to_thread(self.get_games_metadata, ES_GAMES_METADATA),
+ )
+
+ ((self.controllers, self.wheels), self.resolution) = await self.__stack.enter_async_context(
+ parallel(
+ configure_wheels(self.config, controllers, self.metadata),
+ prepare_resolution(self.config.video_mode == 'default', self.target_video_mode),
+ )
+ )
+
+ outputs = await list_outputs(timeout=3)
+
+ if len(outputs) > 1:
+ _logger.debug(
+ 'Multiple displays detected (%s). Resetting mouse to primary display.', ', '.join(outputs)
+ )
+ await _hotkeygen_reset_mouse()
+ else:
+ _logger.debug(
+ 'Single display detected (%s). Skipping mouse reset to keep cursor hidden',
+ ', '.join(outputs) if outputs else 'default',
+ )
+
+ await self.__stack.enter_async_context(prepare_mouse(self.needs_mouse))
+
+ return self
+ except BaseException:
+ await self.__stack.__aexit__(*sys.exc_info())
+ raise
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: TracebackType | None,
+ /,
+ ) -> bool | None:
+ return await self.__stack.__aexit__(exc_type, exc_value, traceback)
+
+ @property
+ def name(self) -> str:
+ return self.config.emulator
+
+ @property
+ def raw_core(self) -> str | None:
+ return self.config.raw_core
+
+ @property
+ def core(self) -> str:
+ return self.config.core
+
+ @property
+ def render_config(self) -> Config:
+ return self.config.render_config
+
+ @property
+ def es_settings(self) -> ESSettings:
+ return self.config.es_settings
+
+ @cached_property
+ @abstractmethod
+ def hotkeygen_context(self) -> HotkeysContext: ...
+
+ @property
+ def execution_path(self) -> Path | None:
+ return None
+
+ @cached_property
+ def roms_dir(self) -> Path:
+ return ROMS / self.system
+
+ @cached_property
+ def bios_dir(self) -> Path:
+ return BIOS / self.system
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / self.name
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / self.system
+
+ @property
+ def target_video_mode(self) -> str:
+ return self.config.video_mode
+
+ @property
+ def needs_mouse(self) -> bool:
+ return False
+
+ @property
+ def handles_bezels(self) -> bool:
+ return False
+
+ @property
+ def handles_hud(self) -> bool:
+ return False
+
+ @property
+ def needs_overlayfs(self) -> bool:
+ return False
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 4 / 3
+
+ @cached_property
+ def guns_borders_size(self) -> str | None:
+ borders_size: str = self.config.get('controllers.guns.borderssize', 'medium')
+
+ # overridden by specific options
+ borders_mode = 'normal'
+ if (config_borders_mode := (self.config.get('controllers.guns.bordersmode') or 'auto')) != 'auto':
+ borders_mode = config_borders_mode
+ if (config_borders_mode := (self.config.get('bordersmode') or 'auto')) != 'auto':
+ borders_mode = config_borders_mode
+
+ # others are gameonly and normal
+ if borders_mode == 'hidden':
+ return None
+
+ if borders_mode == 'force':
+ return borders_size
+
+ for gun in self.guns:
+ if gun.needs_borders:
+ return borders_size
+
+ return None
+
+ @cached_property
+ def guns_border_ratio(self) -> str | None:
+ return self.config.get('controllers.guns.bordersratio', None)
+
+ @cached_property
+ def gun_borders_color(self) -> str:
+ return self.config.get_str('controllers.guns.borderscolor', 'white').lower()
+
+ @cached_property
+ def guns_art_metadata(self) -> Mapping[str, str]:
+ if ES_GUNS_ART_METADATA.exists():
+ return self.get_games_metadata(ES_GUNS_ART_METADATA)
+
+ _logger.info('metadata file not found : %s', ES_GUNS_ART_METADATA)
+ return {}
+
+ @property
+ def guns_need_crosses(self) -> bool:
+ return guns_need_crosses(self.guns)
+
+ @cached_property
+ def game_info(self) -> Mapping[str, str]:
+ values: dict[str, str] = {}
+
+ try:
+ tree = ET.parse(self.game_info_path)
+ root = tree.getroot()
+ for child in root:
+ for metadata in child:
+ values[metadata.tag] = metadata.text or ''
+ except Exception:
+ _logger.debug('An error occurred while reading ES metadata')
+
+ return values
+
+ @cached_property
+ def decoration_id(self) -> str:
+ return 'standalone'
+
+ @cached_property
+ def bezel_files(self) -> BezelFiles | None:
+ # by order choose :
+ # rom name in the system subfolder of the user directory (gb/mario.png)
+ # rom name in the system subfolder of the system directory (gb/mario.png)
+ # rom name in the user directory (mario.png)
+ # rom name in the system directory (mario.png)
+ # system name with special graphic in the user directory (gb-90.png)
+ # system name in the user directory (gb.png)
+ # system name with special graphic in the system directory (gb-90.png)
+ # system name in the system directory (gb.png)
+ # default name (default.png)
+ # else return
+ # mamezip files are for MAME-specific advanced artwork (bezels with overlays and backdrops, animated LEDs, etc)
+
+ bezel = self.config.get_str('bezel', 'none')
+
+ if not bezel or bezel == 'none':
+ return None
+
+ game_id = self.rom.stem
+
+ def candidates() -> Iterator[tuple[Path, bool, Path | None]]:
+ for root in (USER_DECORATIONS, SYSTEM_DECORATIONS):
+ stem = Path(bezel).joinpath('games', self.system, f'{game_id}.png')
+ yield root / stem, True, USER_DECORATIONS.joinpath(stem) if root == SYSTEM_DECORATIONS else None
+ for root in (USER_DECORATIONS, SYSTEM_DECORATIONS):
+ stem = Path(bezel).joinpath('games', f'{game_id}.png')
+ yield root / stem, True, USER_DECORATIONS.joinpath(stem) if root == SYSTEM_DECORATIONS else None
+ for root in (USER_DECORATIONS, SYSTEM_DECORATIONS):
+ if self.decoration_id != '0':
+ yield root / bezel / 'systems' / f'{self.system}-{self.decoration_id}.png', False, None
+ yield root / bezel / 'systems' / f'{self.system}.png', False, None
+ for root in (USER_DECORATIONS, SYSTEM_DECORATIONS):
+ yield root / bezel / f'default-{self.decoration_id}.png', True, None
+ yield root / bezel / 'default.png', True, None
+
+ for png, bezel_game, override in candidates():
+ if png.exists():
+ _logger.debug('Original bezel file used: %s', png)
+ return BezelFiles(
+ png,
+ png.with_suffix('.info'),
+ (override or png).with_suffix('.lay'),
+ (override or png).with_suffix('.zip'),
+ bezel_game,
+ )
+
+ return None
+
+ def get_games_metadata(self, metadata_file: Path) -> dict[str, str]:
+ return get_games_meta_data(metadata_file, self.system, self.rom)
+
+ async def prepare_bezel(self) -> Path | None:
+ if self.handles_bezels:
+ _logger.debug('skipping bezels for emulator %s', self.name)
+ return None
+
+ bezel = self.config.get_str('bezel', 'none')
+ bezel_tattoo = self.config.get_str('bezel.tattoo', '0')
+ bezel_qrcode = self.config.get_str('bezel.qrcode', '0')
+ gun_borders_size = self.guns_borders_size
+
+ if (
+ (not bezel or bezel == 'none')
+ and (not bezel_tattoo or bezel_tattoo == '0')
+ and (not bezel_qrcode or bezel_qrcode == '0')
+ and gun_borders_size is None
+ ):
+ return None
+
+ if not bezel or bezel == 'none':
+ # no bezel, generate a transparent one for the tatoo/gun borders ... and so on
+ overlay_png_path = Path('/tmp/bezel_transhud_black.png')
+ overlay_info_path = Path('/tmp/bezel_transhud_black.info')
+
+ create_transparent_image(overlay_png_path, self.resolution.width, self.resolution.height)
+ overlay_info_path.write_text(
+ f'{{ "width":{self.resolution.width}, "height":{self.resolution.height}, "opacity":1.0000000, "messagex":0.220000, "messagey":0.120000 }}'
+ )
+ else:
+ _logger.debug('hud enabled. trying to apply the bezel %s', bezel)
+ if self.bezel_files is None:
+ _logger.debug('no bezel info file found')
+ return None
+
+ overlay_png_path = self.bezel_files.png
+ overlay_info_path = self.bezel_files.info
+
+ bezel_info = BezelInfo.load_from_json(overlay_info_path)
+ bezel_width = bezel_info.width
+ bezel_height = bezel_info.height
+
+ if bezel_width is None or bezel_height is None:
+ bezel_width, bezel_height = get_image_size(overlay_png_path)
+ _logger.info('bezel size read from %s', overlay_png_path)
+
+ # max cover proportion and ratio distortion
+ max_cover = 0.05 # 5%
+ max_ratio_delta = 0.01
+
+ screen_ratio = self.resolution.width / self.resolution.height
+ bezel_ratio = bezel_width / bezel_height
+
+ if self.guns_borders_size is None:
+ # the screen and bezel ratio must be approximatly the same
+ if abs(screen_ratio - bezel_ratio) > max_ratio_delta:
+ _logger.debug(
+ 'screen ratio (%(screen_ratio)s) is too far from the bezel one (%(bezel_ratio)s) : %(screen_ratio)s - %(bezel_ratio)s > %(max_ratio_delta)s',
+ {'screen_ratio': screen_ratio, 'bezel_ratio': bezel_ratio, 'max_ratio_delta': max_ratio_delta},
+ )
+ return None
+
+ # the ingame image and the bezel free space must feet
+ ## the bezel top and bottom cover must be minimum
+ # in case there is a border, force it
+ if bezel_info.top is not None and bezel_info.top / bezel_height > max_cover:
+ _logger.debug(
+ 'bezel top covers too much the game image : %s / %s > %s', bezel_info.top, bezel_height, max_cover
+ )
+ return None
+ if bezel_info.bottom is not None and bezel_info.bottom / bezel_height > max_cover:
+ _logger.debug(
+ 'bezel bottom covers too much the game image : %s / %s > %s',
+ bezel_info.bottom,
+ bezel_height,
+ max_cover,
+ )
+ return None
+
+ # if there is no information about top/bottom, assume default is 0
+
+ ## the bezel left and right cover must be maximum
+ in_game_ratio = self.in_game_ratio
+ img_height = bezel_height
+ img_width = img_height * in_game_ratio
+
+ if bezel_info.left is None:
+ _logger.debug('bezel has no left info in %s', overlay_info_path)
+ # assume default is 4/3 over 16/9
+ infos_left = (bezel_width - (bezel_height / 3 * 4)) / 2
+ if (
+ self.guns_borders_size is None
+ and abs((infos_left - ((bezel_width - img_width) / 2.0)) / img_width) > max_cover
+ ):
+ _logger.debug(
+ 'bezel left covers too much the game image : %s / %s > %s',
+ infos_left - ((bezel_width - img_width) / 2.0),
+ img_width,
+ max_cover,
+ )
+ return None
+
+ if bezel_info.right is None:
+ _logger.debug('bezel has no right info in %s', overlay_info_path)
+ # assume default is 4/3 over 16/9
+ infos_right = (bezel_width - (bezel_height / 3 * 4)) / 2
+ if (
+ self.guns_borders_size is None
+ and abs((infos_right - ((bezel_width - img_width) / 2.0)) / img_width) > max_cover
+ ):
+ _logger.debug(
+ 'bezel right covers too much the game image : %s / %s > %s',
+ infos_right - ((bezel_width - img_width) / 2.0),
+ img_width,
+ max_cover,
+ )
+ return None
+
+ if self.guns_borders_size is None:
+ if (
+ bezel_info.left is not None
+ and abs((bezel_info.left - ((bezel_width - img_width) / 2.0)) / img_width) > max_cover
+ ):
+ _logger.debug(
+ 'bezel left covers too much the game image : %s / %s > %s',
+ bezel_info.left - ((bezel_width - img_width) / 2.0),
+ img_width,
+ max_cover,
+ )
+ return None
+
+ if (
+ bezel_info.right is not None
+ and abs((bezel_info.right - ((bezel_width - img_width) / 2.0)) / img_width) > max_cover
+ ):
+ _logger.debug(
+ 'bezel right covers too much the game image : %s / %s > %s',
+ bezel_info.right - ((bezel_width - img_width) / 2.0),
+ img_width,
+ max_cover,
+ )
+ return None
+
+ # if screen and bezel sizes doesn't match, resize
+ # stretch option
+ bezel_stretch = self.config.get_bool('bezel_stretch')
+ if bezel_width != self.resolution.width or bezel_height != self.resolution.height:
+ _logger.debug('bezel needs to be resized')
+ output_png_file = Path('/tmp/bezel.png')
+ try:
+ resize_image(
+ overlay_png_path,
+ output_png_file,
+ self.resolution.width,
+ self.resolution.height,
+ stretch=bezel_stretch,
+ )
+ except Exception as e:
+ _logger.error('failed to resize the image %s', e)
+ return None
+ overlay_png_path = output_png_file
+
+ if bezel_tattoo != '0':
+ output_png_file = Path('/tmp/bezel_tattooed.png')
+ add_tattoo_image(overlay_png_path, output_png_file, self.config)
+ overlay_png_path = output_png_file
+
+ if bezel_qrcode != '0' and (cheevos_id := self.game_info.get('cheevosId', '0')) != '0':
+ output_png_file = Path('/tmp/bezel_qrcode.png')
+ add_qr_code(overlay_png_path, output_png_file, cheevos_id, self.config.get_str('bezel.qrcode_corner', 'NE'))
+ overlay_png_path = output_png_file
+
+ # borders
+ if gun_borders_size is not None:
+ _logger.debug('Draw gun borders')
+ output_png_file = Path('/tmp/bezel_gunborders.png')
+ _logger.debug('Gun border ratio = %s', self.guns_border_ratio)
+ create_gun_border_image(
+ overlay_png_path,
+ output_png_file,
+ gun_borders_size,
+ self.guns_border_ratio,
+ inner_color=self.gun_borders_color,
+ )
+ overlay_png_path = output_png_file
+
+ _logger.debug('applying bezel %s', overlay_png_path)
+ return overlay_png_path
+
+ def prepare_hud_config(self, bezel: Path | None, /) -> Path:
+ config_lines: list[str] = []
+
+ if (mode := self.config.get('hud', 'none')) == 'none':
+ config_lines.append('background_alpha=0') # hide the background
+ else:
+ hud_position = 'bottom-left'
+ if (hud_corner := self.config.get('hud_corner', '')) != '':
+ if hud_corner == 'NW':
+ hud_position = 'top-left'
+ elif hud_corner == 'NE':
+ hud_position = 'top-right'
+ elif hud_corner == 'SE':
+ hud_position = 'bottom-right'
+
+ # Font Scaling Calculations
+ screen_height = self.resolution.height
+ font_size = clamp(int(24 * (screen_height / 1080)), 12, 48)
+
+ config_lines.extend(
+ [
+ f'font_size={font_size}',
+ f'font_size_text={font_size}',
+ f'position={hud_position}',
+ ]
+ )
+
+ # Bezel Offset Calculation
+ if (self.config.get_str('bezel') or 'none') != 'none' and (
+ (bezel is not None and bezel.exists()) or self.handles_bezels
+ ):
+ offset_x = 0
+ active_game_width = int(screen_height * self.in_game_ratio)
+ pillar_width = (self.resolution.width - active_game_width) // 2
+
+ if pillar_width > 0:
+ # Push HUD inwards to clear the bezel column
+ if 'left' in hud_position:
+ offset_x = pillar_width + 10 # 10px extra margin inside the game window
+ elif 'right' in hud_position:
+ offset_x = -(pillar_width + 10)
+
+ if offset_x != 0:
+ config_lines.append(f'offset_x={offset_x}')
+
+ # predefined values
+ if mode == 'perf':
+ config_lines.extend(
+ [
+ 'background_alpha=0.9',
+ 'legacy_layout=false',
+ 'custom_text=%GAMENAME%',
+ 'custom_text=%SYSTEMNAME%',
+ 'custom_text=%EMULATORCORE%',
+ 'fps',
+ 'gpu_name',
+ 'engine_version',
+ 'vulkan_driver',
+ 'resolution',
+ 'ram',
+ 'gpu_stats',
+ 'gpu_temp',
+ 'cpu_stats',
+ 'cpu_temp',
+ 'core_load',
+ ]
+ )
+ elif mode == 'game':
+ game_mode_font_size = clamp(int(32 * (screen_height / 1080)), 14, 64)
+
+ config_lines.extend(
+ [
+ 'background_alpha=0',
+ 'legacy_layout=false',
+ f'font_size={game_mode_font_size}',
+ 'image_max_width=200',
+ 'image=%THUMBNAIL%',
+ 'custom_text=%GAMENAME%',
+ 'custom_text=%SYSTEMNAME%',
+ 'custom_text=%EMULATORCORE%',
+ ]
+ )
+ elif mode == 'custom' and (hud_custom := self.config.get_str('hud_custom')):
+ config_lines.extend(hud_custom.replace(r'\n', '\n').splitlines())
+ else:
+ config_lines.append('background_alpha=0') # hide the background
+
+ emulator = self.name
+ if emulator != self.core and self.core:
+ emulator = f'{emulator}/{self.core}'
+
+ game_name = self.game_info.get('name', '')
+ game_thumbnail = self.game_info.get('thumbnail', '')
+
+ config_path = Path('/var/run/hud.config')
+ config_path.write_text(
+ ('\n'.join(config_lines) + '\n')
+ .replace('%SYSTEMNAME%', self.fancy_system_name or '')
+ .replace('%GAMENAME%', game_name or '')
+ .replace('%EMULATORCORE%', emulator or '')
+ .replace('%THUMBNAIL%', game_thumbnail or '')
+ )
+
+ return config_path
+
+ async def prepare_hud(self, command: Command, bezel: Path | None, /) -> None:
+ if not self.config.get_bool('hud_support') or self.config.get('hud', 'none') == 'none':
+ return
+
+ hud_config = self.prepare_hud_config(bezel)
+ command.update_env(
+ MANGOHUD_DLSYM='1',
+ MANGOHUD_CONFIGFILE=hud_config,
+ )
+
+ if not self.handles_hud:
+ command.prepend_args('mangohud')
+
+ def prepare_gun_help(self) -> None:
+ try:
+ generate_gun_help(
+ self.config.use_guns,
+ self.guns,
+ self.guns_art_metadata,
+ self.resolution,
+ )
+ except Exception:
+ _logger.exception('Failed to generate the gun help image')
+
+ def draw_gun_borders(self) -> None:
+ if self.handles_bezels or self.config.get_bool('hud_support'):
+ _logger.debug('skipping drawing gun borders for emulator %s', self.config.emulator)
+ return
+
+ gun_borders_size = self.guns_borders_size
+ if gun_borders_size is not None:
+ _logger.debug('using gun borders for emulator %s', self.name)
+
+ try:
+ from .draw.gun_borders import draw_gun_borders
+
+ draw_gun_borders(gun_borders_size, self.gun_borders_color, self.guns_border_ratio)
+ except Exception:
+ _logger.exception('Failed to draw gun borders')
+
+ def prepare_execution_path(self) -> AbstractContextManager[None]:
+ # TODO: once all generators that call os.chdir directly are ported,
+ # change this to use a nullcontext manager when execution_path is None.
+ # For now, we always chdir to the current working directory if execution_path
+ # is None so that the process is not left in a mounted squashfs directory
+ # that prevents unmounting it later.
+ execution_path = self.execution_path or Path.cwd()
+
+ return chdir(execution_path)
+
+ async def prepare_labwc(self) -> None:
+ return None
+
+ @abstractmethod
+ async def configure(self) -> Command: ...
+
+ def get_sdl_game_controller_config(self) -> str:
+ return generate_sdl_game_controller_config(
+ self.controllers, ignore_buttons=self.sdl_game_controller_config_ignore_buttons
+ )
+
+ def write_sdl_controller_db(self) -> None:
+ self.sdl_controller_db_path.write_text(self.get_sdl_game_controller_config())
+
+ async def run(self) -> int:
+ # SDL VSync is a big deal on OGA and RPi4
+ os.environ.update({'SDL_RENDER_VSYNC': self.config.get_bool('sdlvsync', True, return_values=('1', '0'))})
+
+ async with (
+ script_caller(('gameStart', 'gameStop'), self.system, self.name, self.core, self.rom),
+ EvmapyManager(self) as evmapy_manager,
+ HotkeygenManager(self.hotkeygen_context, self.config.get_bool('exithotkeyonly'), self.config.ui_mode),
+ ):
+ with self.prepare_execution_path():
+ if self.needs_sdl_controller_db:
+ self.write_sdl_controller_db()
+
+ command = await self.configure()
+
+ if 'LABWC_PID' in os.environ:
+ await self.prepare_labwc()
+
+ if self.needs_sdl_game_controller_config:
+ command.update_env(SDL_GAMECONTROLLERCONFIG=self.get_sdl_game_controller_config())
+
+ bezel = await self.prepare_bezel()
+ await self.prepare_hud(command, bezel)
+
+ async with bezel_overlay(bezel, self.resolution):
+ self.prepare_gun_help()
+
+ if self.config.use_guns and self.guns:
+ self.draw_gun_borders()
+
+ with self.profiler.pause():
+ async with evmapy_manager.monitor_controllers():
+ return await command.run()
+
+ @staticmethod
+ def _load_class(emulator_name: str, /) -> type[Emulator]:
+ from importlib.metadata import entry_points
+
+ emulators = entry_points(group='batocera_launch.emulators')
+
+ if emulator_name in emulators.names:
+ try:
+ return emulators[emulator_name].load()
+ except Exception as e:
+ raise UnknownEmulator from e
+
+ if 'configgen' in emulators.names:
+ return emulators['configgen'].load()
+
+ raise UnknownEmulator
+
+ @staticmethod
+ def create(args: Arguments, profiler: Profiler, /) -> Emulator:
+ system_config = SystemConfig.load(args)
+ emulator_cls = Emulator._load_class(system_config.emulator)
+
+ return emulator_cls(system_config, profiler)
+
+
+@cached_dataclass
+class SpecialDecorationsMixin(Emulator):
+ @cached_property
+ def decoration_id(self) -> str:
+ from .config.decoration_id import get_decoration_id
+
+ return get_decoration_id(self.system, self.rom.stem)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/__init__.py b/python-src/batocera-launch/batocera_launch/emulators/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/python-src/batocera-launch/batocera_launch/emulators/abuse.py b/python-src/batocera-launch/batocera_launch/emulators/abuse.py
new file mode 100644
index 00000000000..7b40494c33c
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/abuse.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_ABUSE_DATA: Final = ROMS / 'abuse' / 'abuse_data'
+
+
+@cached_dataclass
+class Abuse(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'abuse',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ return Command(['abuse', '-datadir', _ABUSE_DATA])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/applewin.py b/python-src/batocera-launch/batocera_launch/emulators/applewin.py
new file mode 100644
index 00000000000..34933195016
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/applewin.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.key_value_config import KeyValueConfig
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class AppleWin(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'applewin',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ config_file = self.config_dir / 'config.txt'
+
+ config = KeyValueConfig(' ')
+ config.read(config_file)
+ config.write(config_file)
+
+ return Command(['applewin'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/azahar.py b/python-src/batocera-launch/batocera_launch/emulators/azahar.py
new file mode 100644
index 00000000000..4b589c9a8a5
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/azahar.py
@@ -0,0 +1,406 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.configparser import CaseSensitiveRawConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CACHE, CONFIGS, SAVES, SCREENSHOTS
+from batocera_common.vulkan import get_discrete_gpu_index, has_discrete_gpu, is_available
+from batocera_launch import Command, Controller, Emulator, HotkeysContext, Input
+from batocera_launch.config.labwc import LabWCConfig
+from batocera_launch.devices.video import find_screen, get_screens
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from batocera_launch import InputMapping
+
+_logger = logging.getLogger(__name__)
+
+_BUTTONS: Final = {
+ 'button_a': 'a',
+ 'button_b': 'b',
+ 'button_x': 'x',
+ 'button_y': 'y',
+ 'button_up': 'up',
+ 'button_down': 'down',
+ 'button_left': 'left',
+ 'button_right': 'right',
+ 'button_l': 'pageup',
+ 'button_r': 'pagedown',
+ 'button_start': 'start',
+ 'button_select': 'select',
+ 'button_zl': 'l2',
+ 'button_zr': 'r2',
+ 'button_home': 'hotkey',
+}
+
+_AXIS: Final = {
+ 'circle_pad': 'joystick1',
+ 'c_stick': 'joystick2',
+}
+
+_REGION: Final = {'AUTO': -1, 'JPN': 0, 'USA': 1, 'EUR': 2, 'AUS': 3, 'CHN': 4, 'KOR': 5, 'TWN': 6}
+_AVAILABLE_LANGUAGES: Final = {
+ 'ja_JP': 'JPN',
+ 'en_US': 'USA',
+ 'de_DE': 'EUR',
+ 'es_ES': 'EUR',
+ 'fr_FR': 'EUR',
+ 'it_IT': 'EUR',
+ 'hu_HU': 'EUR',
+ 'pt_PT': 'EUR',
+ 'ru_RU': 'EUR',
+ 'en_AU': 'AUS',
+ 'zh_CN': 'CHN',
+ 'ko_KR': 'KOR',
+ 'zh_TW': 'TWN',
+}
+
+
+def _hat_direction(value: str) -> str:
+ match int(value):
+ case 1:
+ return 'up'
+ case 4:
+ return 'down'
+ case 2:
+ return 'right'
+ case 8:
+ return 'left'
+ case _:
+ return 'unknown'
+
+
+def _set_button(key: str, pad_guid: str, pad_inputs: InputMapping) -> str | None:
+ # It would be better to pass the joystick num instead of the guid because 2 joysticks may have the same guid
+ if (inp := pad_inputs.get(key)) is None:
+ return None
+
+ if inp.type == 'button':
+ return f'button:{inp.id},guid:{pad_guid},engine:sdl'
+ if inp.type == 'hat':
+ return f'engine:sdl,guid:{pad_guid},hat:{inp.id},direction:{_hat_direction(inp.value)}'
+ if inp.type == 'axis':
+ # Untested, need to configure an axis as button / triggers buttons to be tested too
+ return f'engine:sdl,guid:{pad_guid},axis:{inp.id},direction:+,threshold:0.5'
+ return None
+
+
+def _set_axis(key: str, pad_guid: str, pad_inputs: InputMapping) -> str:
+ input_x: Input | None = None
+ input_y: Input | None = None
+
+ if key == 'joystick1' and 'joystick1left' in pad_inputs:
+ input_x = pad_inputs['joystick1left']
+ elif key == 'joystick2' and 'joystick2left' in pad_inputs:
+ input_x = pad_inputs['joystick2left']
+
+ if key == 'joystick1' and 'joystick1up' in pad_inputs:
+ input_y = pad_inputs['joystick1up']
+ elif key == 'joystick2' and 'joystick2up' in pad_inputs:
+ input_y = pad_inputs['joystick2up']
+
+ if input_x is None or input_y is None:
+ return ''
+
+ return f'axis_x:{input_x.id},guid:{pad_guid},axis_y:{input_y.id},engine:sdl'
+
+
+def _azahar_lang_from_config(language: str | None) -> int:
+ lang = (language or '')[:5]
+ if lang in _AVAILABLE_LANGUAGES:
+ return _REGION[_AVAILABLE_LANGUAGES[lang]]
+ return _REGION['AUTO']
+
+
+@cached_dataclass
+class Azahar(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'azahar',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_F4',
+ 'pause': 'KEY_F4',
+ 'reset': 'KEY_F6',
+ 'screen_layout': 'KEY_F10',
+ 'swap_screen': 'KEY_F9',
+ },
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'azahar-emu'
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / '3ds'
+
+ @property
+ def needs_mouse(self) -> bool:
+ return self.config.get_str('azahar_screen_layout') != '1-false'
+
+ async def prepare_labwc(self) -> None:
+ # windows position is handled by coordonnates by the application (xorg) or the windows manager (wayland)
+ screens = await get_screens(self.config)
+ primary = find_screen(screens, 'primary')
+ backglass = find_screen(screens, 'backglass')
+
+ config = LabWCConfig()
+ config.set_move_to_output(identifier='azahar', output=primary)
+ config.set_move_to_output(identifier='azahar', title='*Secondary Window*', output=backglass)
+ config.set_toggle_fullscreen(identifier='azahar', title='*Secondary Window*')
+ config.save()
+
+ def _write_config(self) -> None:
+ config_file = self.config_dir / 'qt-config.ini'
+
+ # ini file
+ azahar_config = CaseSensitiveRawConfigParser(strict=False)
+ if config_file.exists():
+ azahar_config.read(config_file)
+
+ ## [LAYOUT]
+ if not azahar_config.has_section('Layout'):
+ azahar_config.add_section('Layout')
+ # Screen Layout
+ azahar_config.set('Layout', 'custom_layout', 'false')
+ azahar_config.set('Layout', r'custom_layout\default', 'false')
+ layout_option, swap_screen = self.config.get_str('azahar_screen_layout', '0-false').split('-')
+ azahar_config.set('Layout', 'swap_screen', swap_screen)
+ azahar_config.set('Layout', r'swap_screen\default', 'false')
+ azahar_config.set('Layout', 'layout_option', layout_option)
+ azahar_config.set('Layout', r'layout_option\default', 'false')
+ azahar_config.set(
+ 'Layout', 'large_screen_proportion', self.config.get_str('azahar_large_screen_proportion', '4')
+ )
+ azahar_config.set('Layout', r'large_screen_proportion\default', 'false')
+
+ ## [SYSTEM]
+ if not azahar_config.has_section('System'):
+ azahar_config.add_section('System')
+ # New 3DS Version
+ azahar_config.set(
+ 'System',
+ 'is_new_3ds',
+ self.config.get_bool('azahar_is_new_3ds', return_values=('true', 'false')),
+ )
+ azahar_config.set('System', r'is_new_3ds\default', 'false')
+ # Language
+ azahar_config.set(
+ 'System', 'region_value', str(_azahar_lang_from_config(self.config.get_str('system.language')))
+ )
+ azahar_config.set('System', r'region_value\default', 'false')
+
+ ## [CORE]
+ if not azahar_config.has_section('Core'):
+ azahar_config.add_section('Core')
+ # CPU Clock Percentage
+ azahar_config.set('Core', 'cpu_clock_percentage', self.config.get_str('azahar_cpu_clock', '100'))
+ azahar_config.set('Core', r'cpu_clock_percentage\default', 'false')
+
+ ## [UI]
+ if not azahar_config.has_section('UI'):
+ azahar_config.add_section('UI')
+ # Start Fullscreen
+ azahar_config.set('UI', 'fullscreen', 'true')
+ azahar_config.set('UI', r'fullscreen\default', 'false')
+
+ # Batocera - Defaults
+ azahar_config.set('UI', 'display_titlebar', 'false')
+ azahar_config.set('UI', r'display_titlebar\default', 'false')
+ azahar_config.set('UI', 'first_start', 'false')
+ azahar_config.set('UI', r'first_start\default', 'false')
+ azahar_config.set('UI', 'hide_mouse', 'true')
+ azahar_config.set('UI', r'hide_mouse\default', 'false')
+ azahar_config.set('UI', 'enable_discord_presence', 'false')
+ azahar_config.set('UI', r'enable_discord_presence\default', 'false')
+
+ # Remove pop-up prompt on start
+ azahar_config.set('UI', 'callout_flags', '1')
+ azahar_config.set('UI', r'callout_flags\default', 'false')
+ # Close without confirmation
+ azahar_config.set('UI', 'confirm_before_closing', 'false')
+ azahar_config.set('UI', r'confirm_before_closing\default', 'false')
+
+ # screenshots
+ azahar_config.set('UI', r'Paths\screenshot_path', str(SCREENSHOTS))
+ azahar_config.set('UI', r'Paths\screenshot_path\default', 'false')
+
+ ## [MISCELLANEOUS]
+ if not azahar_config.has_section('Miscellaneous'):
+ azahar_config.add_section('Miscellaneous')
+ # Don't check for update at start
+ azahar_config.set('Miscellaneous', 'check_for_update_on_start', 'false')
+ azahar_config.set('Miscellaneous', r'check_for_update_on_start\default', 'false')
+
+ ## [RENDERER]
+ if not azahar_config.has_section('Renderer'):
+ azahar_config.add_section('Renderer')
+ # Hardware Shader
+ azahar_config.set(
+ 'Renderer',
+ 'use_hw_shader',
+ self.config.get_bool('azahar_use_hw_shader', False, return_values=('true', 'false')),
+ )
+ azahar_config.set('Renderer', r'use_hw_shader\default', 'false')
+ # Accurate Multiplication
+ azahar_config.set(
+ 'Renderer',
+ 'shaders_accurate_mul',
+ self.config.get_bool('azahar_accurate_multiplication', False, return_values=('true', 'false')),
+ )
+ azahar_config.set('Renderer', r'shaders_accurate_mul\default', 'false')
+ # Shader JIT
+ azahar_config.set(
+ 'Renderer',
+ 'use_shader_jit',
+ self.config.get_bool('azahar_use_shader_jit', True, return_values=('true', 'false')),
+ )
+ azahar_config.set('Renderer', r'use_shader_jit\default', 'false')
+ # Async Shader Compilation
+ azahar_config.set(
+ 'Renderer',
+ 'async_shader_compilation',
+ self.config.get_bool('azahar_async_shader_compilation', False, return_values=('true', 'false')),
+ )
+ azahar_config.set('Renderer', r'async_shader_compilation\default', 'false')
+ # Async Presentation
+ azahar_config.set(
+ 'Renderer',
+ 'async_presentation',
+ self.config.get_bool('azahar_async_presentation', False, return_values=('true', 'false')),
+ )
+ azahar_config.set('Renderer', r'async_presentation\default', 'false')
+ # Software, OpenGL (default) or Vulkan
+ azahar_config.set('Renderer', 'graphics_api', self.config.get_str('azahar_graphics_api', '1'))
+ azahar_config.set('Renderer', r'graphics_api\default', 'false')
+ # Set Vulkan as necessary
+ if self.config.get_str('azahar_graphics_api') == '2' and is_available():
+ _logger.debug('Vulkan driver is available on the system.')
+ if has_discrete_gpu():
+ _logger.debug('A discrete GPU is available on the system. We will use that for performance')
+ discrete_index = get_discrete_gpu_index()
+ if discrete_index:
+ _logger.debug('Using Discrete GPU Index: %s for Azahar', discrete_index)
+ azahar_config.set('Renderer', 'physical_device', discrete_index)
+ azahar_config.set('Renderer', r'physical_device\default', 'false')
+ else:
+ _logger.debug("Couldn't get discrete GPU index")
+ else:
+ _logger.debug('Discrete GPU is not available on the system. Using default.')
+ # Use VSYNC
+ azahar_config.set(
+ 'Renderer',
+ 'use_vsync',
+ self.config.get_bool('azahar_use_vsync', True, return_values=('true', 'false')),
+ )
+ azahar_config.set('Renderer', r'use_vsync\default', 'false')
+ # Resolution Factor
+ azahar_config.set('Renderer', 'resolution_factor', self.config.get_str('azahar_resolution_factor', '1'))
+ azahar_config.set('Renderer', r'resolution_factor\default', 'false')
+ # Texture Filter
+ azahar_config.set('Renderer', 'texture_filter', self.config.get_str('azahar_texture_filter', '0'))
+ azahar_config.set('Renderer', r'texture_filter\default', 'false')
+
+ ## [AUDIO]
+ if not azahar_config.has_section('Audio'):
+ azahar_config.add_section('Audio')
+ # Audio Stretching
+ azahar_config.set(
+ 'Audio',
+ 'enable_audio_stretching',
+ self.config.get_bool('azahar_audio_stretching', True, return_values=('true', 'false')),
+ )
+ azahar_config.set('Audio', r'enable_audio_stretching\default', 'false')
+
+ ## [WEB SERVICE]
+ if not azahar_config.has_section('WebService'):
+ azahar_config.add_section('WebService')
+ azahar_config.set('WebService', 'enable_telemetry', 'false')
+ azahar_config.set('WebService', r'enable_telemetry\default', 'false')
+
+ ## [UTILITY]
+ if not azahar_config.has_section('Utility'):
+ azahar_config.add_section('Utility')
+ # Disk Shader Cache
+ azahar_config.set(
+ 'Utility',
+ 'use_disk_shader_cache',
+ self.config.get_bool('azahar_use_disk_shader_cache', return_values=('true', 'false')),
+ )
+ azahar_config.set('Utility', r'use_disk_shader_cache\default', 'false')
+ # Custom Textures
+ match self.config.get_str('azahar_custom_textures'):
+ case None | '0':
+ azahar_config.set('Utility', 'custom_textures', 'false')
+ azahar_config.set('Utility', r'custom_textures\default', 'false')
+ azahar_config.set('Utility', 'preload_textures', 'false')
+ azahar_config.set('Utility', r'preload_textures\default', 'false')
+ case textures:
+ tab = textures.split('-')
+ azahar_config.set('Utility', 'custom_textures', 'true')
+ azahar_config.set('Utility', r'custom_textures\default', 'false')
+ if tab[1] == 'normal':
+ azahar_config.set('Utility', 'async_custom_loading', 'true')
+ azahar_config.set('Utility', r'async_custom_loading\default', 'false')
+ azahar_config.set('Utility', 'preload_textures', 'false')
+ azahar_config.set('Utility', r'preload_textures\default', 'false')
+ else:
+ azahar_config.set('Utility', 'async_custom_loading', 'false')
+ azahar_config.set('Utility', r'async_custom_loading\default', 'false')
+ azahar_config.set('Utility', 'preload_textures', 'true')
+ azahar_config.set('Utility', r'preload_textures\default', 'false')
+
+ ## [CONTROLS]
+ if not azahar_config.has_section('Controls'):
+ azahar_config.add_section('Controls')
+
+ # Options required to load the functions when the configuration file is created
+ if not azahar_config.has_option('Controls', r'profiles\size'):
+ azahar_config.set('Controls', 'profile', '0')
+ azahar_config.set('Controls', r'profile\default', 'false')
+ azahar_config.set('Controls', r'profiles\1\name', 'default')
+ azahar_config.set('Controls', r'profiles\1\name\default', 'false')
+ azahar_config.set('Controls', r'profiles\size', '1')
+ azahar_config.set('Controls', r'profiles\size\default', 'false')
+
+ if controller := Controller.find_player_number(self.controllers, 1):
+ for option, button in _BUTTONS.items():
+ azahar_config.set(
+ 'Controls',
+ f'profiles\\1\\{option}',
+ f'"{_set_button(button, controller.guid, controller.inputs)}"',
+ )
+ azahar_config.set('Controls', f'profiles\\1\\{option}\\default', 'false')
+ for option, axis in _AXIS.items():
+ azahar_config.set(
+ 'Controls',
+ f'profiles\\1\\{option}',
+ f'"{_set_axis(axis, controller.guid, controller.inputs)}"',
+ )
+ azahar_config.set('Controls', f'profiles\\1\\{option}\\default', 'false')
+
+ ## Update the configuration file
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ with config_file.open('w') as fp:
+ azahar_config.write(fp)
+
+ async def configure(self) -> Command:
+ self._write_config()
+
+ return Command(
+ ['/usr/bin/azahar', self.rom],
+ env={
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_DATA_HOME': self.saves_dir,
+ 'XDG_CACHE_HOME': CACHE,
+ 'XDG_RUNTIME_DIR': self.saves_dir / 'azahar-emu',
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/bstone.py b/python-src/batocera-launch/batocera_launch/emulators/bstone.py
new file mode 100644
index 00000000000..0ba53dccfcb
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/bstone.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class BStone(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'bstone',
+ 'keys': {
+ 'exit': 'KEY_F10',
+ 'save_state': 'KEY_F2',
+ 'restore_state': 'KEY_F3',
+ 'menu': 'KEY_ESC',
+ 'screenshot': 'KEY_F5',
+ },
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'bstone'
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ if self.config.get_bool('bstone_widescreen') and self.config.get_bool('bstone_ui_stretched'):
+ return 16 / 9
+
+ return 4 / 3
+
+ def _update_or_create_config(self) -> None:
+ config_lines: list[str] = []
+
+ config_lines.append(f'vid_width "{self.resolution.width}"\n')
+ config_lines.append(f'vid_height "{self.resolution.height}"\n')
+
+ # Configuration options
+ config_lines.append(f'vid_is_widescreen "{1 if self.config.get_bool("bstone_widescreen") else 0}"\n')
+ config_lines.append(f'vid_is_vsync "{1 if self.config.get_bool("bstone_vsync") else 0}"\n')
+ config_lines.append(f'vid_is_ui_stretched "{1 if self.config.get_bool("bstone_ui_stretched") else 0}"\n')
+
+ # Handle existing file or create a new file
+ config_file = self.config_dir / 'bstone_config.txt'
+ if config_file.exists():
+ existing_lines = []
+ with config_file.open('r') as f:
+ existing_lines = f.readlines()
+
+ with config_file.open('w') as f:
+ for line in config_lines:
+ # Check for a match in the existing lines
+ match = False
+ for i, existing_line in enumerate(existing_lines):
+ if line.split('"')[0] in existing_line:
+ existing_lines[i] = line
+ match = True
+ break
+
+ # If there was no match, add to the config
+ if not match:
+ existing_lines.append(line)
+
+ f.writelines(existing_lines)
+
+ else:
+ # Create new file with all config lines
+ with config_file.open('w') as f:
+ f.writelines(config_lines)
+
+ async def configure(self) -> Command:
+ self._update_or_create_config()
+
+ rom_dir = self.rom.parent
+
+ filename_to_flag = {
+ 'audiohed.bs1': '--aog_sw',
+ 'audiohed.bs6': '--aog',
+ 'audiohed.vsi': '--ps',
+ }
+
+ version_flags: set[str] = set()
+ for file in rom_dir.iterdir():
+ if file.is_file() and (version_flag := filename_to_flag.get(file.name.lower())) is not None:
+ version_flags.add(version_flag)
+
+ return Command(
+ ['/usr/bin/bstone', '--profile_dir', self.config_dir, '--data_dir', rom_dir, *version_flags],
+ env={'SDL_JOYSTICK_HIDAPI': '0'},
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/cannonball.py b/python-src/batocera-launch/batocera_launch/emulators/cannonball.py
new file mode 100644
index 00000000000..4c6389ad74f
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/cannonball.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Cannonball(Emulator):
+ needs_sdl_game_controller_config = True
+ needs_sdl_controller_db = True
+ sdl_controller_db_path = CONFIGS / 'cannonball' / 'gamecontrollerdb.txt'
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'cannonball',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ # Data Section
+ data = ET.Element('data')
+ ET.SubElement(data, 'rompath').text = str(self.roms_dir) + '/'
+ ET.SubElement(data, 'respath').text = str(self.config_dir) + '/'
+ ET.SubElement(data, 'savepath').text = str(self.saves_dir) + '/'
+ ET.SubElement(data, 'crc32').text = '0'
+
+ # Video Section
+ video = ET.Element('video')
+ ET.SubElement(video, 'mode').text = '1' # Fullscreen
+ window = ET.SubElement(video, 'window')
+ ET.SubElement(window, 'scale').text = '2'
+ ET.SubElement(video, 'fps_counter').text = '1' if self.config.show_fps else '0'
+ ET.SubElement(video, 'widescreen').text = self.config.get('ratio', '0')
+ ET.SubElement(video, 'hires').text = self.config.get('highResolution', '0')
+ ET.SubElement(video, 'vsync').text = self.config.get('vsync', '1') # default vsync to 1
+ ET.SubElement(video, 'scanlines').text = self.config.get('scanlines', '0')
+ ET.SubElement(video, 'fps').text = self.config.get('fps', '2') # 60 FPS default
+
+ # Sound Section
+ # OutRun shipped with a corrupt PCM sample ROM. This uses the repaired ROM 'opr-10188.71f'
+ sound = ET.Element('sound')
+ ET.SubElement(sound, 'enable').text = '1'
+ ET.SubElement(sound, 'fix_samples').text = '0' # run without it
+ ET.SubElement(sound, 'advertise').text = '1'
+ ET.SubElement(sound, 'preview').text = '1'
+
+ # Engine Section
+ engine = ET.Element('engine')
+ ET.SubElement(engine, 'time').text = self.config.get('time_limit', '1')
+ ET.SubElement(engine, 'traffic').text = self.config.get('traffic_level', '1')
+ ET.SubElement(engine, 'freeplay').text = '0'
+ ET.SubElement(engine, 'japanese_tracks').text = '0'
+ ET.SubElement(engine, 'prototype').text = '0'
+ ET.SubElement(engine, 'levelobjects').text = '1'
+ ET.SubElement(engine, 'fix_bugs').text = self.config.get('fix_bugs', '1')
+ ET.SubElement(engine, 'fix_timer').text = '0'
+ ET.SubElement(engine, 'new_attract').text = '1'
+ ET.SubElement(engine, 'offroad').text = self.config.get('cheats_offroad', '0')
+ ET.SubElement(engine, 'grippy_tyres').text = self.config.get('cheats_grippy_tyres', '0')
+ ET.SubElement(engine, 'bumper').text = '0'
+ ET.SubElement(engine, 'turbo').text = self.config.get('cheats_turbo', '0')
+ ET.SubElement(engine, 'car_color').text = self.config.get('car_colour', '0')
+
+ # Controls Section
+ controls = ET.Element('controls')
+ ET.SubElement(controls, 'gear').text = self.config.get('gear_mode', '3') # default, automatic
+
+ # Function to convert XML to pretty-printed
+ def prettify(element: ET.Element) -> bytes:
+ ET.indent(element, space=' ')
+ return ET.tostring(element, encoding='unicode').encode('utf-8')
+
+ # Save the config file with multiple sections
+ config_file = self.config_dir / 'config.xml'
+ with config_file.open('wb') as cannonballXml:
+ cannonballXml.write(b'\n')
+ cannonballXml.write(prettify(data))
+ cannonballXml.write(b'\n')
+ cannonballXml.write(prettify(video))
+ cannonballXml.write(b'\n')
+ cannonballXml.write(prettify(sound))
+ cannonballXml.write(b'\n')
+ cannonballXml.write(prettify(engine))
+ cannonballXml.write(b'\n')
+ cannonballXml.write(prettify(controls))
+
+ return Command(
+ ['/usr/bin/cannonball', '-cfgfile', config_file],
+ env={
+ 'XDG_DATA_HOME': CONFIGS,
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/catacombgl.py b/python-src/batocera-launch/batocera_launch/emulators/catacombgl.py
new file mode 100644
index 00000000000..8f7f164a2f3
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/catacombgl.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, SAVES
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+
+@cached_dataclass
+class CatacombGL(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'catacombgl',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': ['KEY_F3'],
+ 'restore_state': ['KEY_F4'],
+ 'menu': 'KEY_ESC',
+ },
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'CatacombGL'
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / 'CatacombGL'
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ self.saves_dir.mkdir(parents=True, exist_ok=True)
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ # Path to the ini file
+ config_file = self.config_dir / 'CatacombGL.ini'
+
+ # Check if the ini file exists, and if not, create and adjust it
+ if not config_file.exists():
+ _logger.debug('CatacombGL.ini not found, creating the file.')
+ config_file.touch() # Create the file if it doesn't exist
+
+ # Define the paths to be added or adjusted in the ini file
+ required_paths = {
+ 'pathabyssv113': self.roms_dir / 'Abyss_sw13',
+ 'pathabyssv124': self.roms_dir / 'Abyss',
+ 'patharmageddonv102': self.roms_dir / 'Armageddon',
+ 'pathapocalypsev101': self.roms_dir / 'Apocalypse',
+ 'pathcatacomb3dv122': self.roms_dir / 'Cat3D',
+ 'screenmode': 'fullscreen',
+ 'WindowedScreenWidth': str(self.resolution.width),
+ 'WindowedScreenHeight': str(self.resolution.height),
+ }
+
+ # Read the existing file content
+ ini_content = config_file.read_text().splitlines() if config_file.exists() else []
+ ini_dict: dict[str, str | Path] = {
+ line.split('=')[0]: line.split('=')[1] for line in ini_content if '=' in line
+ }
+ ini_dict.update(required_paths)
+
+ # Update or add required paths
+ with config_file.open('w') as ini_file:
+ for key, value in ini_dict.items():
+ ini_file.write(f'{key}={value}\n')
+
+ # Run command
+ args = ['/usr/bin/CatacombGL', '--savedir', self.saves_dir]
+
+ # Version
+ rom_file_name = self.rom.id.lower()
+
+ # Check and extend the command array with specific arguments
+ for keyword, argument in {
+ 'abyss': '--abyss',
+ 'abyss_sw13': '--abyss_sw13',
+ 'descent': '--descent',
+ 'cat3d': '--descent',
+ 'armageddon': '--armageddon',
+ 'apocalypse': '--apocalypse',
+ }.items():
+ if keyword in rom_file_name:
+ _logger.debug('Version requested: %s', keyword)
+ args.append(argument)
+
+ return Command(args, env={'SDL_JOYSTICK_HIDAPI': '0'})
diff --git a/python-src/batocera-launch/batocera_launch/emulators/clk.py b/python-src/batocera-launch/batocera_launch/emulators/clk.py
new file mode 100644
index 00000000000..17197143913
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/clk.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import shutil
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS
+from batocera_launch import BatoceraException, Command, Emulator, HotkeysContext
+from batocera_launch.paths import SYSTEM_ES_DIR, USER_ES_DIR
+
+# Static temp file for extraction; CLK doesn't support zipped roms.
+_TMP_DIR: Final = Path('/tmp/clk_extracted')
+_QUICKLOAD_SYSTEMS: Final = {
+ 'amstradcpc',
+ 'archimedes',
+ 'electron',
+ 'msx1',
+ 'msx2',
+ 'oricatmos',
+ 'zxspectrum',
+}
+_SVIDEO_SYSTEMS: Final = {'colecovision', 'mastersystem'}
+_RGB_SYSTEMS: Final = {
+ 'amstradcpc',
+ 'atarist',
+ 'electron',
+ 'enterprise',
+ 'msx1',
+ 'msx2',
+ 'oricatmos',
+ 'zxspectrum',
+}
+_ES_SYSTEMS_DIRS: Final = (SYSTEM_ES_DIR, USER_ES_DIR)
+
+# Archive containers are never the loadable file we extract from a zip.
+_ARCHIVE_EXTENSIONS: Final = {'zip', '7z'}
+
+
+def _supported_extensions(system_name: str, /) -> set[str]:
+ """Loadable file extensions for a system from es_systems*.cfg (user overrides last)."""
+ extensions: set[str] = set()
+ for es_dir in _ES_SYSTEMS_DIRS:
+ for config in sorted(es_dir.glob('es_systems*.cfg')):
+ try:
+ root = ET.parse(config).getroot()
+ except ET.ParseError, OSError:
+ continue
+ for system in root.iter('system'):
+ if system.findtext('name') != system_name:
+ continue
+ found = {
+ ext.lstrip('.').lower() for ext in (system.findtext('extension') or '').split()
+ } - _ARCHIVE_EXTENSIONS
+ if found:
+ extensions = found # later configs override earlier ones
+ return extensions
+
+
+def _openzip_file(file_path: Path, valid_extensions: set[str] | None = None, /) -> Path | None:
+ if not file_path.is_file():
+ return None
+
+ if _TMP_DIR.exists():
+ shutil.rmtree(_TMP_DIR) # Remove extracted zip files (can't be done upon return from configgen)
+
+ if file_path.suffix.lower() != '.zip':
+ return file_path
+
+ with zipfile.ZipFile(file_path, 'r') as zip_ref:
+ # Prefer the largest file with a loadable extension, so CLK can identify
+ # the target machine. Fall back to the largest file overall.
+ best_valid: zipfile.ZipInfo | None = None
+ largest_info: zipfile.ZipInfo | None = None
+ for info in zip_ref.infolist():
+ # Skip directories
+ if info.is_dir():
+ continue
+
+ if largest_info is None or info.file_size > largest_info.file_size:
+ largest_info = info
+
+ if (
+ valid_extensions
+ and Path(info.filename).suffix.lower().lstrip('.') in valid_extensions
+ and (best_valid is None or info.file_size > best_valid.file_size)
+ ):
+ best_valid = info
+
+ chosen = best_valid or largest_info
+ if chosen is None:
+ return None
+
+ zip_ref.extractall(_TMP_DIR)
+ return _TMP_DIR / chosen.filename
+
+
+@cached_dataclass
+class Clk(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'clk',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ rom = _openzip_file(self.rom, _supported_extensions(self.system))
+
+ if rom is None:
+ raise BatoceraException(f'ROM is a directory: {self.rom}')
+
+ args: list[str | Path] = ['clksignal', rom, f'--rompath={BIOS}/']
+
+ if self.system in _SVIDEO_SYSTEMS:
+ args.append('--output=SVideo')
+ if self.system in _RGB_SYSTEMS:
+ args.append('--output=RGB')
+ if self.system in _QUICKLOAD_SYSTEMS:
+ args.append('--accelerate-media-loading')
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/corsixth.py b/python-src/batocera-launch/batocera_launch/emulators/corsixth.py
new file mode 100644
index 00000000000..fd33923dd84
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/corsixth.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import SCREENSHOTS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_logger = logging.getLogger(__name__)
+
+_FONT_PATH: Final = Path('/usr/share/fonts/dejavu/DejaVuSans.ttf')
+
+_ASSET_DIR_NAMES: Final = ['ANIMS', 'DATA', 'INTRO', 'LEVELS', 'QDATA']
+
+_LANGUAGE_MAPPING: Final = {
+ 'en_US': 'en',
+ 'en_GB': 'en',
+ 'fr_FR': 'fr',
+ 'oc_FR': 'fr',
+ 'de_DE': 'de',
+ 'es_ES': 'es',
+ 'es_MX': 'es',
+ 'it_IT': 'it',
+ 'nl_NL': 'nl',
+ 'ru_RU': 'ru',
+ 'sv_SE': 'sv',
+ 'cs_CZ': 'cs',
+ 'fi_FI': 'fi',
+ 'pl_PL': 'pl',
+ 'hu_HU': 'hu',
+ 'pt_PT': 'pt',
+ 'pt_BR': 'br',
+ 'zh_CN': 'zh(s)',
+ 'zh_TW': 'zh(t)',
+ 'ko_KR': 'ko',
+ 'nb_NO': 'nb',
+ 'nn_NO': 'nb',
+}
+
+
+@cached_dataclass
+class CorsixTH(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'corsixth',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': ['KEY_RIGHTSHIFT', 'KEY_Q'],
+ 'reset': ['KEY_RIGHTSHIFT', 'KEY_F10'],
+ 'save_state': ['KEY_LEFTALT', 'KEY_LEFTSHIFT', 'KEY_S'],
+ 'restore_state': ['KEY_LEFTALT', 'KEY_LEFTSHIFT', 'KEY_L'],
+ },
+ }
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ self.saves_dir.mkdir(parents=True, exist_ok=True)
+
+ if not all((self.roms_dir / name).is_dir() for name in _ASSET_DIR_NAMES):
+ _logger.error('ERROR: Game assets not installed. You can get them from the game Theme Hospital.')
+
+ language = _LANGUAGE_MAPPING.get(self.config.get_str('system.language', 'en_US'), 'en')
+ music_dir = self.roms_dir / 'MP3'
+ if music_dir.is_dir():
+ audio_music = f'[[{music_dir}]]'
+ else:
+ _logger.warning(
+ 'NOTICE: Audio & Music system loaded, but found no external background tracks. Missing MP3 folder'
+ )
+ audio_music = 'nil'
+
+ config_file = self.config_dir / 'config.txt'
+ config_file.write_text(
+ '\n'.join(
+ [
+ 'check_for_updates = false',
+ f'theme_hospital_install = [[{self.roms_dir}]]',
+ f'unicode_font = [[{_FONT_PATH}]]',
+ f'savegames = [[{self.saves_dir}]]',
+ f'screenshots = [[{SCREENSHOTS}]]',
+ 'fullscreen = true',
+ f'width = {self.resolution.width}',
+ f'height = {self.resolution.height}',
+ f'use_new_graphics = {self.config.get_str("cth_new_graphics", "true")}',
+ f'free_build_mode = {self.config.get_str("cth_free_build_mode", "false")}',
+ f'play_intro = {self.config.get_str("cth_play_intro", "true")}',
+ f'language = [[{language}]]',
+ f'audio_music = {audio_music}',
+ '',
+ ]
+ ),
+ encoding='utf-8',
+ )
+
+ return Command(['corsix-th', f'--config-file={config_file}'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/devilutionx.py b/python-src/batocera-launch/batocera_launch/emulators/devilutionx.py
new file mode 100644
index 00000000000..168667a156b
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/devilutionx.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import configparser
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_DATA_DIR: Final = ROMS / 'devilutionx'
+
+
+@cached_dataclass
+class DevilutionX(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'devilutionx',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F2',
+ 'restore_state': 'KEY_F3',
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.config.get_bool('devilutionx_stretch') else 4 / 3
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ self.saves_dir.mkdir(parents=True, exist_ok=True)
+
+ config = configparser.ConfigParser()
+ config_file = self.config_dir / 'diablo.ini'
+
+ if config_file.exists():
+ config.read(config_file)
+
+ if 'Graphics' not in config:
+ config['Graphics'] = {}
+
+ config['Graphics']['Fit to Screen'] = self.config.get_bool('devilutionx_stretch', return_values=('1', '0'))
+
+ with config_file.open('w') as file:
+ config.write(file)
+
+ args: list[str | Path] = [
+ 'devilutionx',
+ '--data-dir',
+ _DATA_DIR,
+ '--config-dir',
+ self.config_dir,
+ '--save-dir',
+ self.saves_dir,
+ ]
+
+ if self.rom.name.endswith('hellfire.mpq'):
+ args.append('--hellfire')
+ elif self.rom.name.endswith('spawn.mpq'):
+ args.append('--spawn')
+ else:
+ args.append('--diablo')
+
+ if self.config.show_fps:
+ args.append('-f')
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/dhewm3.py b/python-src/batocera-launch/batocera_launch/emulators/dhewm3.py
new file mode 100644
index 00000000000..6aca444fa35
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/dhewm3.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, SAVES
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+_D3XP_MODS: Final = {'perfected_roe', 'sikkmodd3xp', 'bloodmod_roe'}
+
+
+def _update_config_file(file_path: Path, options_to_set: dict[str, str], /) -> None:
+ if file_path.is_file():
+ lines = file_path.read_text(encoding='utf-8').splitlines(keepends=True)
+
+ for key, value in options_to_set.items():
+ option_line = f'{key} "{value}"\n'
+ if any(key in line for line in lines):
+ lines = [option_line if key in line else line for line in lines]
+ else:
+ lines.append(option_line)
+
+ file_path.write_text(''.join(lines), encoding='utf-8')
+ else:
+ file_path.write_text(
+ ''.join(f'{key} "{value}"\n' for key, value in options_to_set.items()),
+ encoding='utf-8',
+ )
+
+
+@cached_dataclass
+class Dhewm3(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'dhewm3',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': 'KEY_F5',
+ 'restore_state': 'KEY_F9',
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ with self.rom.open(encoding='utf-8') as file:
+ directory = file.readline().strip().split('/')[0]
+ _logger.debug('Using directory: %s', directory)
+
+ config_base_dir = self.config_dir / 'base'
+ config_subdir = self.config_dir / directory
+ config_base_file = config_base_dir / 'dhewm.cfg'
+ config_file = config_subdir / 'dhewm.cfg'
+
+ config_base_dir.mkdir(parents=True, exist_ok=True)
+ config_subdir.mkdir(parents=True, exist_ok=True)
+
+ options_to_set = {
+ 'seta r_mode': '-1',
+ 'seta r_fullscreen': '1',
+ 'seta r_customHeight': str(self.resolution.height),
+ 'seta r_customWidth': str(self.resolution.width),
+ 'bind "JOY_BTN_SOUTH"': '_moveUp',
+ 'bind "JOY_BTN_EAST"': '_moveDown',
+ 'bind "JOY_BTN_WEST"': '_impulse19',
+ 'bind "JOY_BTN_NORTH"': '_impulse13',
+ 'bind "JOY_BTN_LSTICK"': '_strafe',
+ 'bind "JOY_BTN_RSTICK"': '_speed',
+ 'bind "JOY_BTN_LSHOULDER"': '_impulse15',
+ 'bind "JOY_BTN_RSHOULDER"': '_impulse14',
+ 'bind "JOY_STICK1_UP"': '_forward',
+ 'bind "JOY_STICK1_DOWN"': '_back',
+ 'bind "JOY_STICK1_LEFT"': '_moveLeft',
+ 'bind "JOY_STICK1_RIGHT"': '_moveRight',
+ 'bind "JOY_STICK2_UP"': '_lookUp',
+ 'bind "JOY_STICK2_DOWN"': '_lookDown',
+ 'bind "JOY_STICK2_LEFT"': '_left',
+ 'bind "JOY_STICK2_RIGHT"': '_right',
+ 'bind "JOY_TRIGGER2"': '_attack',
+ 'seta r_brightness': self.config.get_str('dhewm3_brightness', '1'),
+ 'seta sys_lang': self.config.get_str('dhewm3_language', 'english'),
+ }
+
+ _update_config_file(config_base_file, options_to_set)
+ _update_config_file(config_file, options_to_set)
+
+ args: list[str | Path] = ['/usr/bin/dhewm3', '+set', 'fs_basepath', self.roms_dir]
+
+ if directory in _D3XP_MODS:
+ args.extend(['+set', 'fs_game_base', 'd3xp'])
+
+ if directory == 'd3le':
+ args.extend(['+set', 'fs_game_base', 'd3xp', '+seta', 'com_allowconsole', '1'])
+
+ if directory != 'base':
+ args.extend(['+set', 'fs_game', directory])
+
+ return Command(
+ args,
+ env={
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_DATA_HOME': SAVES,
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/dosbox.py b/python-src/batocera-launch/batocera_launch/emulators/dosbox.py
new file mode 100644
index 00000000000..caa1a34d00c
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/dosbox.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import (
+ Command,
+ Emulator,
+ HotkeysContext,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class DosBox(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'dosbox',
+ 'keys': {'exit': ['KEY_LEFTCTRL', 'KEY_F9']},
+ }
+
+ async def configure(self) -> Command:
+ bat_file = self.rom / 'dosbox.bat'
+ game_conf_file = self.rom / 'dosbox.cfg'
+
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ ini_settings = CaseSensitiveConfigParser(interpolation=None)
+
+ # Use a separate file from dosbox.conf to avoid overwriting by dosbox
+ custom_config_path = self.config_dir / 'dosbox-custom.conf'
+ if custom_config_path.exists():
+ ini_settings.read(custom_config_path)
+
+ if not ini_settings.has_section('sdl'):
+ ini_settings.add_section('sdl')
+ ini_settings.set('sdl', 'output', 'opengl')
+
+ if not ini_settings.has_section('cpu'):
+ ini_settings.add_section('cpu')
+
+ ini_settings.set('cpu', 'core', self.config.get_str('dosbox_cpu_core', 'auto'))
+ ini_settings.set('cpu', 'cputype', self.config.get_str('dosbox_cpu_cputype', 'auto'))
+ ini_settings.set('cpu', 'cycles', self.config.get_str('dosbox_cpu_cycles', 'auto'))
+
+ with custom_config_path.open('w') as config:
+ ini_settings.write(config)
+
+ args: list[str | Path] = [
+ '/usr/bin/dosbox',
+ '-fullscreen',
+ # This loads self.config_dir / dosbox.conf
+ '-userconf',
+ '-exit',
+ bat_file,
+ '-c',
+ f'set ROOT={self.rom}',
+ ]
+
+ if game_conf_file.exists():
+ # Then load game_conf_file if it exists
+ args.extend(['-conf', game_conf_file])
+
+ # Then load custom_config_path after all the others
+ args.extend(['-conf', custom_config_path])
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/dosbox_staging.py b/python-src/batocera-launch/batocera_launch/emulators/dosbox_staging.py
new file mode 100644
index 00000000000..4021085fb58
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/dosbox_staging.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+def _find_iname(directory: Path, filename: str) -> Path | None:
+ if not directory.is_dir():
+ return None
+
+ lower_filename = filename.lower()
+ return next(
+ (f for f in directory.iterdir() if f.is_file() and f.name.lower() == lower_filename),
+ None,
+ )
+
+
+@cached_dataclass
+class DosBoxStaging(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'dosboxstaging',
+ 'keys': {'exit': ['KEY_LEFTCTRL', 'KEY_F9']},
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'dosbox'
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @property
+ def needs_overlayfs(self) -> bool:
+ return self.config.get_bool('dosbox_staging_writes_to_rom')
+
+ async def configure(self) -> Command:
+ # Handle the single-file ROM case
+ game_dir = self.rom if self.rom.is_dir() else self.rom.parent
+
+ common_resource_conf = _find_iname(self.config_dir, 'dosbox-staging.conf')
+
+ dosbox_cfg = _find_iname(game_dir, 'dosbox.cfg')
+ dosbox_conf = _find_iname(game_dir, 'dosbox.conf')
+ dosbox_bat = _find_iname(game_dir, 'dosbox.bat')
+
+ is_configured = dosbox_cfg or dosbox_conf or dosbox_bat
+
+ args: list[str | Path] = [
+ '/usr/bin/dosbox-staging',
+ '--fullscreen',
+ '--working-dir',
+ game_dir,
+ '-c',
+ f'set WORKDIR={game_dir}',
+ ]
+
+ if self.config_dir.is_dir():
+ args.extend(['-c', f'set RESDIR={self.config_dir}'])
+
+ if common_resource_conf:
+ args.extend(['-c', f'set RESCONF={common_resource_conf}'])
+
+ if dosbox_cfg:
+ args.extend(['--conf', dosbox_cfg.name, '-c', f'set GAMECFG={dosbox_cfg.name}'])
+ elif dosbox_conf:
+ args.extend(['--conf', dosbox_conf.name, '-c', f'set GAMECONF={dosbox_conf.name}'])
+
+ if dosbox_bat:
+ args.extend([dosbox_bat.name, '-c', f'set GAMEBAT={dosbox_bat.name}'])
+
+ if is_configured:
+ # If the game's configured, then we can disable the startup logos and
+ # automatically exit when the game quits.
+ args.extend(['--set', 'startup_verbosity=quiet', '--exit'])
+ else:
+ # If the game's not configured, then place the user at a valid C:\>
+ # prompt inside the game's root directory.
+ args.extend(
+ [
+ '-c',
+ '@echo off',
+ '-c',
+ 'mount c .',
+ '-c',
+ 'c:',
+ ]
+ )
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/dosboxx.py b/python-src/batocera-launch/batocera_launch/emulators/dosboxx.py
new file mode 100644
index 00000000000..dffe83e47ef
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/dosboxx.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import shutil
+from typing import TYPE_CHECKING
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS
+from batocera_launch import (
+ Command,
+ Emulator,
+ HotkeysContext,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class DosBoxx(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'dosboxx',
+ 'keys': {'exit': ['KEY_LEFTCTRL', 'KEY_F9']},
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'dosbox'
+
+ async def configure(self) -> Command:
+ game_conf_file = self.rom / 'dosbox.cfg'
+ config_file = game_conf_file if game_conf_file.is_file() else (self.config_dir / 'dosboxx.conf')
+
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ ini_settings = CaseSensitiveConfigParser(interpolation=None)
+ custom_config_file = self.config_dir / 'dosboxx-custom.conf'
+
+ # Copy config file to custom config file to avoid overwriting by dosbox-x
+ if config_file.exists():
+ shutil.copy2(config_file, custom_config_file)
+ ini_settings.read(custom_config_file)
+
+ if not ini_settings.has_section('sdl'):
+ ini_settings.add_section('sdl')
+ ini_settings.set('sdl', 'output', 'opengl')
+
+ with custom_config_file.open('w') as config:
+ ini_settings.write(config)
+
+ # -fullscreen removed as it crashes on N2
+ args: list[str | Path] = ['/usr/bin/dosbox-x', '-exit']
+
+ autoexec_file = self.rom / 'dosbox.aut'
+ if autoexec_file.exists():
+ # Read dosbox.aut and append it to the custom config file
+ with custom_config_file.open('a+') as f1:
+ f1.write(autoexec_file.read_text())
+
+ # Setting the defaultdir to the rom dir.
+ # This way we can use relative paths to the rom directory
+ # in dosbox.auto
+ args.extend(['-defaultdir', str(self.rom)])
+ else:
+ # Otherwise, mount the rom directory as c: and run dosbox.bat
+ args.extend(
+ [
+ '-c',
+ f'mount c {self.rom}',
+ '-c',
+ 'c:',
+ '-c',
+ 'dosbox.bat',
+ ]
+ )
+
+ args.extend(['-fastbioslogo', '-conf', str(custom_config_file)])
+
+ return Command(args, env={'XDG_CONFIG_HOME': CONFIGS})
diff --git a/python-src/batocera-launch/batocera_launch/emulators/dxx_rebirth.py b/python-src/batocera-launch/batocera_launch/emulators/dxx_rebirth.py
new file mode 100644
index 00000000000..39e77c869ff
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/dxx_rebirth.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS
+from batocera_launch import BatoceraException, Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_ROM_BINARIES: Final = {
+ '.d1x': 'd1x-rebirth',
+ '.d2x': 'd2x-rebirth',
+}
+
+
+@cached_dataclass
+class DXXRebirth(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'dxx_rebirth',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_F2',
+ 'pause': 'KEY_F2',
+ 'save_state': ['KEY_LEFTALT', 'KEY_F2'],
+ 'restore_state': ['KEY_LEFTALT', 'KEY_LEFTSHIFT', 'KEY_F2'],
+ },
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ @cached_property
+ def rom_binary(self) -> str:
+ if (dxx_rebirth := _ROM_BINARIES.get(self.rom.suffix)) is None:
+ raise BatoceraException(f'Unknown rom type: {self.rom}')
+
+ return dxx_rebirth
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / self.rom_binary
+
+ async def configure(self) -> Command:
+ config_file = self.config_dir / 'descent.cfg'
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ replacements = {
+ 'ResolutionX=': f'ResolutionX={self.resolution.width}',
+ 'ResolutionY=': f'ResolutionY={self.resolution.height}',
+ 'WindowMode=': 'WindowMode=0',
+ 'VSync=': f'VSync={self.config.get_str("rebirth_vsync", "0")}',
+ 'TexFilt=': f'TexFilt={self.config.get_str("rebirth_filtering", "0")}',
+ 'TexAnisotropy=': f'TexAnisotropy={self.config.get_str("rebirth_anisotropy", "0")}',
+ 'Multisample=': f'Multisample={self.config.get_str("rebirth_multisample", "0")}',
+ }
+
+ if config_file.is_file():
+ lines = config_file.read_text().splitlines(keepends=True)
+
+ for i, line in enumerate(lines):
+ for prefix, replacement in replacements.items():
+ if line.startswith(prefix):
+ lines[i] = f'{replacement}\n'
+ break
+
+ config_file.write_text(''.join(lines))
+ else:
+ config_file.write_text(
+ '\n'.join(
+ [
+ f'ResolutionX={self.resolution.width}',
+ f'ResolutionY={self.resolution.height}',
+ 'WindowMode=0',
+ 'VSync=0',
+ 'TexFilt=0',
+ 'TexAnisotropy=0',
+ 'Multisample=0',
+ '',
+ ]
+ ),
+ )
+
+ return Command([self.rom_binary, '-hogdir', self.rom.parent])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/easyrpg.py b/python-src/batocera-launch/batocera_launch/emulators/easyrpg.py
new file mode 100644
index 00000000000..4404a85daf0
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/easyrpg.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import SAVES
+from batocera_launch import Command, Controller, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from batocera_launch import Controllers
+
+_KEYMAPPING: Final[dict[str, str | None]] = {
+ 'button_up': None,
+ 'button_down': None,
+ 'button_left': None,
+ 'button_right': None,
+ 'button_action': 'a',
+ 'button_cancel': 'b',
+ 'button_shift': 'pageup',
+ 'button_n0': None,
+ 'button_n1': None,
+ 'button_n2': None,
+ 'button_n3': None,
+ 'button_n4': None,
+ 'button_n5': None,
+ 'button_n6': None,
+ 'button_n7': None,
+ 'button_n8': None,
+ 'button_n9': None,
+ 'button_plus': None,
+ 'button_minus': None,
+ 'button_multiply': None,
+ 'button_divide': None,
+ 'button_period': None,
+ 'button_debug_menu': None,
+ 'button_debug_through': None,
+}
+
+
+def _write_pad_config(config_dir: Path, controllers: Controllers, /) -> None:
+ with (config_dir / 'config.ini').open('w', encoding='ascii') as f:
+ f.write('[Joypad]\n')
+ if pad := Controller.find_player_number(controllers, 1):
+ f.write(f'number={pad.index}\n')
+ for key, value in _KEYMAPPING.items():
+ button: str | int = -1
+ if value is not None and pad.inputs[value].type == 'button':
+ button = pad.inputs[value].id
+ f.write(f'{key}={button}\n')
+
+
+@cached_dataclass
+class EasyRPG(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'easyrpg',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_F9',
+ 'pause': 'KEY_ESC',
+ 'restore_state': 'KEY_F12',
+ 'save_state': 'KEY_F11',
+ 'rewind': 'KEY_F',
+ },
+ }
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / 'easyrpg' / self.rom.name
+
+ async def configure(self) -> Command:
+ args: list[str | Path] = ['easyrpg-player']
+
+ if self.config.show_fps:
+ args.append('--show-fps')
+
+ if self.config.get_bool('testplay'):
+ args.append('--test-play')
+
+ encoding = self.config.get_str('encoding', 'auto')
+ args.extend(['--encoding', 'auto' if encoding == 'autodetect' else encoding])
+
+ self.saves_dir.mkdir(parents=True, exist_ok=True)
+ args.extend(['--save-path', self.saves_dir])
+
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ args.extend(['--project-path', self.rom])
+
+ _write_pad_config(self.config_dir, self.controllers)
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/ecwolf.py b/python-src/batocera-launch/batocera_launch/emulators/ecwolf.py
new file mode 100644
index 00000000000..3899d3e0e4a
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/ecwolf.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from shlex import split
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, SAVES
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_logger = logging.getLogger(__name__)
+
+_IGNORE_CONFIG_KEYS: Final = {'FullScreenWidth', 'FullScreenHeight', 'JoystickEnabled'}
+_DEFAULT_CONFIG: Final = """\
+Vid_FullScreen = 1;
+Vid_Aspect = 0;
+Vid_Vsync = 1;
+QuitOnEscape = 1;
+"""
+
+
+@cached_dataclass
+class ECWolf(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'ecwolf',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F8',
+ 'restore_state': 'KEY_F9',
+ },
+ }
+
+ @property
+ def saves_path(self) -> Path:
+ return SAVES / 'ecwolf' / self.rom.name
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self._launch_info[0]
+
+ @cached_property
+ def _launch_info(self) -> tuple[Path | None, list[str]]:
+ if self.rom.is_dir():
+ return self.rom, []
+
+ extra: list[str] = []
+ cwd: Path | None = None
+ suffix = self.rom.suffix.lower()
+
+ if self.rom.is_file():
+ cwd = self.rom.parent
+
+ if suffix == '.ecwolf':
+ extra = split(self.rom.read_text())
+
+ # If first parameter isn't an argument then assume it's a path
+ if extra and '--' not in extra[0]:
+ dataset_dir = Path(extra[0])
+ extra = extra[1:]
+ if dataset_dir.is_dir():
+ cwd = dataset_dir
+ else:
+ _logger.error("Error: couldn't go into directory %s", dataset_dir)
+
+ elif suffix == '.pk3':
+ extra = ['--file', self.rom.name]
+
+ return cwd, extra
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ self.saves_path.mkdir(parents=True, exist_ok=True)
+
+ config_file = self.config_dir / 'ecwolf.cfg'
+
+ if not config_file.is_file():
+ config_file.write_text(_DEFAULT_CONFIG)
+
+ lines = [
+ line
+ for line in config_file.read_text().splitlines(keepends=True)
+ if not _IGNORE_CONFIG_KEYS.intersection(line.split())
+ ]
+ lines.extend(
+ [
+ 'JoystickEnabled = 1;\n',
+ f'FullScreenWidth = {self.resolution.width};\n',
+ f'FullScreenHeight = {self.resolution.height};\n',
+ ]
+ )
+ config_file.write_text(''.join(lines))
+
+ return Command(
+ ['ecwolf', *self._launch_info[1], '--savedir', self.saves_path],
+ env={
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_DATA_HOME': SAVES,
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/eduke32.py b/python-src/batocera-launch/batocera_launch/emulators/eduke32.py
new file mode 100644
index 00000000000..b704a1ccb28
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/eduke32.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, SAVES, SCREENSHOTS
+from batocera_launch import (
+ Command,
+ Emulator,
+ HotkeysContext,
+ parse_build_engine_args,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class EDuke32(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'eduke32',
+ 'keys': {
+ 'exit': 'killall -9 eduke32; killall -9 fury',
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F6',
+ 'restore_state': 'KEY_F9',
+ 'screenshot': 'KEY_F12',
+ },
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / self.core
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / self.core
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ config_file = self.config_dir / f'{self.core}.cfg'
+ script_file = self.config_dir / 'autoexec.cfg'
+
+ if not config_file.exists():
+ config_file.touch()
+
+ # https://wiki.eduke32.com/wiki/Configuration_file_options
+ # Not all listed options work (e.g. showFPS). Booleans must be integers.
+ parser = CaseSensitiveConfigParser(interpolation=None)
+ with config_file.open() as config:
+ parser.read_file(config)
+
+ if not parser.has_section('Screen Setup'):
+ parser.add_section('Screen Setup')
+ parser.set('Screen Setup', 'ScreenWidth', str(self.resolution.width))
+ parser.set('Screen Setup', 'ScreenHeight', str(self.resolution.height))
+ parser.set('Screen Setup', 'ScreenMode', '1')
+
+ with config_file.open('w') as config:
+ parser.write(config)
+
+ script_file.write_text(
+ '\n'.join(
+ [
+ '// This file is automatically generated by batocera-launch',
+ 'bind "F12" "screenshot"',
+ f'screenshot_dir "{SCREENSHOTS}"',
+ f'r_showfps "{1 if self.config.show_fps else 0}"',
+ 'echo BATOCERA',
+ '',
+ ]
+ )
+ )
+
+ args: list[str | Path] = [self.core, '-cfg', config_file]
+ if self.config.get_bool('nologo'):
+ args.append('-nologo')
+
+ if self.core == 'fury':
+ args.extend(['-gamegrp', self.rom.name, '-j', self.rom.parent])
+ else:
+ args.extend(parse_build_engine_args(self.rom, existing=args))
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/etlegacy.py b/python-src/batocera-launch/batocera_launch/emulators/etlegacy.py
new file mode 100644
index 00000000000..189877a9307
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/etlegacy.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_LEGACY_DIR: Final = ROMS / 'etlegacy' / 'legacy'
+_LEGACY_FILE: Final = 'legacy_2.85-dirty.pk3'
+_LEGACY_SOURCE: Final = Path('/usr/share/etlegacy') / _LEGACY_FILE
+_LEGACY_DEST: Final = _LEGACY_DIR / _LEGACY_FILE
+
+
+@cached_dataclass
+class ETLegacy(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'etlegacy',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ },
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'etlegacy' / 'legacy'
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ _LEGACY_DIR.mkdir(parents=True, exist_ok=True)
+
+ scope_color = self.config.get_str('etlegacy_scope_color', '#000000FF')
+ language = self.config.get_str('etlegacy_language', 'en')
+
+ options_to_set = {
+ 'seta r_mode': '-1',
+ 'seta r_fullscreen': '1',
+ 'seta r_allowResize': '0',
+ 'seta r_centerWindow': '1',
+ 'seta r_customheight': str(self.resolution.height),
+ 'seta r_customwidth': str(self.resolution.width),
+ 'seta cg_scopeReticleStyle': self.config.get_str('etlegacy_scope_style', '0'),
+ 'seta cg_scopeReticleColor': scope_color,
+ 'seta cg_scopeReticleDotColor': scope_color,
+ 'seta cg_weapzoomSensitivityScale': self.config.get_str('etlegacy_zoom_sens', '1.0'),
+ 'seta cl_lang': language,
+ 'seta ui_cl_lang': language,
+ }
+
+ lines: list[str] = []
+ config_file = self.config_dir / 'etconfig.cfg'
+
+ if config_file.is_file():
+ lines = config_file.read_text().splitlines()
+
+ for key, value in options_to_set.items():
+ option_line = f'{key} "{value}"'
+ if any(key in line for line in lines):
+ lines = [option_line if key in line else line for line in lines]
+ else:
+ lines.append(option_line)
+ else:
+ lines = [f'{key} "{value}"' for key, value in options_to_set.items()]
+
+ if not lines or lines[-1] != '':
+ # Ensure the file ends with a newline
+ lines.append('')
+
+ config_file.write_text('\n'.join(lines))
+
+ if not _LEGACY_DEST.exists() or _LEGACY_SOURCE.stat().st_mtime > _LEGACY_DEST.stat().st_mtime:
+ shutil.copy(_LEGACY_SOURCE, _LEGACY_DEST)
+
+ return Command(['etl'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/fba2x.py b/python-src/batocera-launch/batocera_launch/emulators/fba2x.py
new file mode 100644
index 00000000000..0fb3c6a1abf
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/fba2x.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, LOGS
+from batocera_launch import Command, Controller, Controllers, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_CONFIG_FILE: Final = CONFIGS / 'fba' / 'fba2x.cfg'
+
+_RATIO_INDEXES: Final = {'4/3': '0', '16/9': '1'}
+
+# Map an emulationstation button name to the corresponding fba2x name
+_FBA_4BTNS: Final = {
+ 'a': 'Y',
+ 'b': 'X',
+ 'x': 'B',
+ 'y': 'A',
+ 'pageup': 'L',
+ 'pagedown': 'R',
+ 'start': 'START',
+ 'select': 'SELECT',
+}
+_FBA_6BTNS: Final = {
+ 'a': 'L',
+ 'b': 'Y',
+ 'x': 'X',
+ 'y': 'A',
+ 'pageup': 'B',
+ 'pagedown': 'R',
+ 'start': 'START',
+ 'select': 'SELECT',
+}
+
+# Map an emulationstation direction to the corresponding fba2x
+_FBA_DIRS: Final = {'up': 'UP', 'down': 'DOWN', 'left': 'LEFT', 'right': 'RIGHT'}
+_FBA_AXIS: Final = {'joystick1up': 'JA_UD', 'joystick1left': 'JA_LR'}
+
+# Map buttons to the corresponding fba2x specials keys
+_FBA_SPECIALS: Final = {'start': 'QUIT', 'hotkey': 'HOTKEY'}
+_SIX_BTN_GAMES: Final = (
+ 'sfa',
+ 'sfz',
+ 'sf2',
+ 'dstlk',
+ 'hsf2',
+ 'msh',
+ 'mshvsf',
+ 'mvsc',
+ 'nwarr',
+ 'ssf2',
+ 'vsav',
+ 'vhunt',
+ 'xmvsf',
+ 'xmcota',
+)
+
+
+def _is_six_btn(rom: Path) -> bool:
+ return any(game in rom.name for game in _SIX_BTN_GAMES)
+
+
+def _update_graphics(ini: CaseSensitiveConfigParser, emulator: Emulator, /) -> None:
+ if not ini.has_section('Graphics'):
+ ini.add_section('Graphics')
+
+ ini.set('Graphics', 'DisplaySmoothStretch', emulator.config.get_bool('smooth', return_values=('1', '0')))
+ ini.set(
+ 'Graphics',
+ 'MaintainAspectRatio',
+ _RATIO_INDEXES.get(emulator.config.get_str('ratio', '4/3'), '0'),
+ )
+ ini.set('Graphics', 'DisplayEffect', '1' if emulator.config.get_str('shaders') == 'scanlines' else '0')
+ ini.set('Graphics', 'RotateScreen', '0')
+ ini.set('Graphics', 'DisplayBorder', '0')
+
+
+def _update_controller(
+ ini: CaseSensitiveConfigParser,
+ player: int,
+ controller: Controller,
+ *,
+ six_btn: bool,
+) -> None:
+ buttons = _FBA_6BTNS if six_btn else _FBA_4BTNS
+
+ for dir_key, dir_value in _FBA_DIRS.items():
+ if (inp := controller.inputs.get(dir_key)) is not None and inp.type == 'button':
+ ini.set('Joystick', f'{dir_value}_{player}', inp.id)
+
+ for axis_key, axis_value in _FBA_AXIS.items():
+ if (inp := controller.inputs.get(axis_key)) is not None:
+ ini.set('Joystick', f'{axis_value}_{player}', inp.id)
+
+ for btn_key, btn_value in buttons.items():
+ if (inp := controller.inputs.get(btn_key)) is not None:
+ ini.set('Joystick', f'{btn_value}_{player}', inp.id)
+
+ if player == 1:
+ for btn_key, btn_value in _FBA_SPECIALS.items():
+ if (inp := controller.inputs.get(btn_key)) is not None:
+ ini.set('Joystick', btn_value, inp.id)
+
+
+def _update_controllers(ini: CaseSensitiveConfigParser, rom: Path, controllers: Controllers, /) -> None:
+ # remove any previous section to remove all configured keys
+ if ini.has_section('Joystick'):
+ ini.remove_section('Joystick')
+ ini.add_section('Joystick')
+
+ # indexes
+ for player in range(1, 5):
+ ini.set('Joystick', f'SDLID_{player}', '-1')
+ for controller in controllers:
+ ini.set('Joystick', f'SDLID_{controller.player_number}', str(controller.index))
+
+ six_btn = _is_six_btn(rom)
+ for controller in controllers:
+ _update_controller(ini, controller.player_number, controller, six_btn=six_btn)
+
+
+@cached_dataclass
+class Fba2x(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'fba2x',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'fba'
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ ini = CaseSensitiveConfigParser(interpolation=None)
+ if _CONFIG_FILE.exists():
+ ini.read(_CONFIG_FILE)
+
+ _update_graphics(ini, self)
+ _update_controllers(ini, self.rom, self.controllers)
+
+ # save the ini file
+ with _CONFIG_FILE.open('w') as configfile:
+ ini.write(configfile)
+
+ return Command(['/usr/bin/fba2x', '--configfile', _CONFIG_FILE, '--logfile', LOGS / 'fba2x.log', self.rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/flatpak.py b/python-src/batocera-launch/batocera_launch/emulators/flatpak.py
new file mode 100644
index 00000000000..1743973fb3a
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/flatpak.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from batocera_common.asyncio import run
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Flatpak(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'flatpak',
+ 'keys': {'exit': 'flatpak kill $(flatpak ps --columns=application | head -n 1)'},
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ async def configure(self) -> Command:
+ rom_id = self.rom.read_text().strip()
+
+ # bad hack in a first time to get audio for user batocera
+ await run('chown', '-R', 'root:audio', '/var/run/pulse', check=False)
+ await run('chmod', '-R', 'g+rwX', '/var/run/pulse', check=False)
+
+ return Command(
+ ['/usr/bin/flatpak', 'run', '-v', rom_id],
+ env={'SDL_JOYSTICK_HIDAPI_XBOX': '0'},
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/gsplus.py b/python-src/batocera-launch/batocera_launch/emulators/gsplus.py
new file mode 100644
index 00000000000..53c8f6ff972
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/gsplus.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.key_value_config import KeyValueConfig
+from batocera_common.paths import BIOS, CONFIGS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_FLOPPY_SUFFIXES: Final = {'.dsk', '.do', '.nib'}
+
+_DSK_SETTINGS: Final = {
+ 'bram1[00]': '00 00 00 01 00 00 0d 06 02 01 01 00 01 00 00 00',
+ 'bram1[10]': '00 00 07 06 02 01 01 00 00 00 0f 06 06 00 05 06',
+ 'bram1[20]': '01 00 00 00 00 00 00 01 06 00 00 00 03 02 02 02',
+ 'bram1[30]': '00 00 00 00 00 00 00 00 00 00 01 02 03 04 05 06',
+ 'bram1[40]': '07 00 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d',
+ 'bram1[50]': '0e 0f ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[60]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[70]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[80]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[90]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[a0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[b0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[c0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[d0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[e0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[f0]': 'ff ff ff ff ff ff ff ff ff ff ff ff fe 17 54 bd',
+ 'bram3[00]': '00 00 00 01 00 00 0d 06 02 01 01 00 01 00 00 00',
+ 'bram3[10]': '00 00 07 06 02 01 01 00 00 00 0f 06 00 00 05 06',
+ 'bram3[20]': '01 00 00 00 00 00 00 01 00 00 00 00 05 02 02 00',
+ 'bram3[30]': '00 00 2d 2d 00 00 00 00 00 00 02 02 02 06 08 00',
+ 'bram3[40]': '01 02 03 04 05 06 07 0a 00 01 02 03 04 05 06 07',
+ 'bram3[50]': '08 09 0a 0b 0c 0d 0e 0f 00 00 ff ff ff ff ff ff',
+ 'bram3[60]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[70]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[80]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[90]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[a0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[b0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[c0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[d0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[e0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram3[f0]': 'ff ff ff ff ff ff ff ff ff ff ff ff 05 cf af 65',
+ 'g_limit_speed': '1',
+}
+
+_PO_SETTINGS: Final = {
+ 'bram1[00]': '00 00 00 01 00 00 0d 06 02 01 01 00 01 00 00 00',
+ 'bram1[10]': '00 00 07 06 02 01 01 00 00 00 0f 06 06 00 05 06',
+ 'bram1[20]': '01 00 00 00 00 00 00 01 00 00 00 00 03 02 02 02',
+ 'bram1[30]': '00 00 00 00 00 00 00 00 08 00 01 02 03 04 05 06',
+ 'bram1[40]': '07 0a 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d',
+ 'bram1[50]': '0e 0f ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[60]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[70]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[80]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[90]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[a0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[b0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[c0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[d0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[e0]': 'ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff',
+ 'bram1[f0]': 'ff ff ff ff ff ff ff ff ff ff ff ff 13 24 b9 8e',
+ 'bram3[00]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[10]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[20]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[30]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[40]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[50]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[60]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[70]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[80]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[90]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[a0]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[b0]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[c0]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[d0]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[e0]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'bram3[f0]': '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00',
+ 'g_limit_speed': '2',
+}
+
+
+@cached_dataclass
+class GSplus(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'gsplus',
+ 'keys': {
+ 'exit': ['KEY_LEFTSHIFT', 'KEY_F6'],
+ 'menu': 'KEY_F4',
+ 'pause': 'KEY_F4',
+ },
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ # The GSplus binary looks in this mixed-case path (see 001-paths.patch).
+ return CONFIGS / 'GSplus'
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ config_file = self.config_dir / 'config.txt'
+
+ config = KeyValueConfig(' ')
+ config.read(config_file)
+
+ if self.rom.suffix.lower() in _FLOPPY_SUFFIXES:
+ config['s6d1'] = str(self.rom)
+ config['s5d1'] = ''
+ config['s7d1'] = ''
+ settings = _DSK_SETTINGS
+ else:
+ config['s7d1'] = str(self.rom)
+ config['s5d1'] = ''
+ config['s6d1'] = ''
+ settings = _PO_SETTINGS
+
+ for key, value in settings.items():
+ config[key] = value
+
+ config['g_cfg_rom_path'] = str(BIOS / self.config.get_str('gsplus_bios_filename', 'ROM.03'))
+ config.write(config_file)
+
+ return Command(['GSplus', '-fullscreen'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/hatari.py b/python-src/batocera-launch/batocera_launch/emulators/hatari.py
new file mode 100644
index 00000000000..ecc66dbd0a6
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/hatari.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS
+from batocera_launch import BatoceraException, Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+_MODEL_MAPPING: Final = {
+ '520st_auto': ('st', 'auto'),
+ '520st_100': ('st', '100'),
+ '520st_102': ('st', '102'),
+ '520st_104': ('st', '104'),
+ '520st_etos256': ('st', 'etos256'),
+ '1040ste_auto': ('ste', 'auto'),
+ '1040ste_106': ('ste', '106'),
+ '1040ste_162': ('ste', '162'),
+ '1040ste_etos256': ('ste', 'etos256'),
+ 'megaste_auto': ('megaste', 'auto'),
+ 'megaste_205': ('megaste', '205'),
+ 'megaste_206': ('megaste', '206'),
+ 'megaste_etos256': ('megaste', 'etos256'),
+ 'tt_auto': ('tt', 'auto'),
+ 'tt_306': ('tt', '306'),
+ 'tt_etos512': ('tt', 'etos512'),
+ 'falcon_auto': ('falcon', 'auto'),
+ 'falcon_400': ('falcon', '400'),
+ 'falcon_402': ('falcon', '402'),
+ 'falcon_404': ('falcon', '404'),
+ 'falcon_etos512': ('falcon', 'etos512'),
+}
+
+# all languages by preference, when value is "auto"
+_ALL_LANGUAGES: Final = ('us', 'uk', 'de', 'es', 'fr', 'it', 'nl', 'ru', 'se', '')
+
+# machine bioses by prefered orders, when value is "auto"
+_ALL_MACHINES_BIOS: Final = {
+ 'st': ('etos256', '104', '102', '100'),
+ 'ste': ('etos256', '162', '106'),
+ 'megaste': ('etos256', '206', '205'),
+ 'tt': ('etos512', '306'),
+ 'falcon': ('etos512', '404', '402', '400'),
+}
+_PAD_MAPPING: Final = {1: 'y', 2: 'b', 3: 'a'}
+
+
+def _find_best_tos(bios_dir: Path, machine: str, tos_version: str, language: str, /) -> Path:
+ if machine not in _ALL_MACHINES_BIOS:
+ raise BatoceraException(f'No bios found for machine {machine}')
+
+ tos_versions = [tos_version] if tos_version != 'auto' else []
+ tos_versions.extend(_ALL_MACHINES_BIOS[machine])
+
+ for version in tos_versions:
+ languages = [language] if language != 'auto' else []
+ languages.extend(_ALL_LANGUAGES)
+ for lang in languages:
+ bios_version = version if 'etos' in version else f'tos{version}'
+ tos_path = bios_dir / f'{bios_version}{lang}.img'
+ if tos_path.exists():
+ _logger.debug('tos filename: %s', tos_path.name)
+ return tos_path
+ _logger.warning('tos filename %s not found', tos_path.name)
+
+ raise BatoceraException(f'No bios found for machine {machine}')
+
+
+@cached_dataclass
+class Hatari(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'hatari',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ # Machine can be st (default), ste, megaste, tt, falcon
+ # st should use TOS 1.00 to TOS 1.04 (tos100 / tos102 / tos104 / emutos192k)
+ # ste should use TOS 1.06 at least (tos106 / tos162 / tos206 / emutos192K)
+ # megaste should use TOS 2.XX series (tos206 / emutos256k)
+ # tt should use tos 3.XX / emutos512k
+ # falcon should use tos 4.XX / emutos512k
+
+ machine, tos_version = _MODEL_MAPPING.get(self.config.get_str('model', 'none'), ('st', 'auto'))
+ tos_lang = self.config.get_str('language', 'us')
+
+ args: list[str | Path] = [
+ 'hatari',
+ # Start emulator fullscreen
+ '--fullscreen',
+ '--machine',
+ machine,
+ '--tos',
+ _find_best_tos(BIOS, machine, tos_version, tos_lang),
+ # RAM (ST Ram) options (0 for 512k, 1 for 1MB)
+ '--memsize',
+ self.config.get_str('ram', '0'),
+ ]
+
+ suffix = self.rom.suffix.lower()
+ if suffix == '.hd':
+ drive = '--acsi' if self.config.get_str('hatari_drive') == 'ACSI' else '--ide-master'
+ args.extend([drive, self.rom])
+ elif suffix == '.gemdos':
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ blank_file = self.config_dir / 'blank.st'
+ if not blank_file.exists():
+ blank_file.touch()
+ args.extend(['--harddrive', self.rom, blank_file])
+ else:
+ args.extend(
+ [
+ # Floppy (A) options
+ '--disk-a',
+ self.rom,
+ # Floppy (B) options
+ '--drive-b',
+ 'off',
+ ]
+ )
+
+ config = CaseSensitiveConfigParser(interpolation=None)
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ config_file = self.config_dir / 'hatari.cfg'
+ if config_file.is_file():
+ config.read(config_file)
+
+ for i in range(1, 6):
+ section = f'Joystick{i}'
+ if config.has_section(section):
+ config.set(section, 'nJoyId', '-1')
+ config.set(section, 'nJoystickMode', '0')
+
+ # pads
+ # disable previous configuration
+ for pad in self.controllers[:5]: # 1 to 5 included
+ section = f'Joystick{pad.player_number}'
+ if not config.has_section(section):
+ config.add_section(section)
+ config.set(section, 'nJoyId', str(pad.index))
+ config.set(section, 'nJoystickMode', '1')
+
+ for button, name in _PAD_MAPPING.items():
+ key = f'nButton{button}'
+ if name in pad.inputs:
+ config.set(section, key, str(pad.inputs[name].id))
+ else:
+ config.set(section, key, str(button - 1))
+
+ # Log
+ if not config.has_section('Log'):
+ config.add_section('Log')
+ config.set('Log', 'bConfirmQuit', 'FALSE')
+
+ # Screen
+ if not config.has_section('Screen'):
+ config.add_section('Screen')
+ config.set('Screen', 'bShowStatusbar', str(self.config.show_fps).upper())
+
+ with config_file.open('w') as fp:
+ config.write(fp)
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/hcl.py b/python-src/batocera-launch/batocera_launch/emulators/hcl.py
new file mode 100644
index 00000000000..1fe9d645fdb
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/hcl.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+_ROM_DIR: Final = ROMS / 'hcl'
+
+
+@cached_dataclass
+class Hcl(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'hcl',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return _ROM_DIR
+
+ async def configure(self) -> Command:
+ if not (_ROM_DIR / 'data' / 'map').is_dir():
+ _logger.error('ERROR: Game assets not installed. You can get them from the Batocera Content Downloader.')
+
+ return Command(['hcl', '-d'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/hurrican.py b/python-src/batocera-launch/batocera_launch/emulators/hurrican.py
new file mode 100644
index 00000000000..1ea37a3011a
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/hurrican.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+_ROM_DIR: Final = ROMS / 'hurrican'
+
+
+@cached_dataclass
+class Hurrican(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'hurrican',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return _ROM_DIR
+
+ async def configure(self) -> Command:
+ if not (_ROM_DIR / 'data' / 'levels').is_dir():
+ _logger.error('ERROR: Game assets not installed. You can get them from the Batocera Content Downloader.')
+
+ return Command(['hurrican'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/ikemen.py b/python-src/batocera-launch/batocera_launch/emulators/ikemen.py
new file mode 100644
index 00000000000..aaf4e72f4d3
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/ikemen.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING, Final, TypedDict
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+class _PadConfig(TypedDict):
+ Joystick: int
+ Buttons: list[str]
+
+
+_UNUSED_BUTTONS: Final = ['Not used'] * 14
+
+_KEY_MAPPING: Final[list[_PadConfig]] = [
+ {
+ 'Joystick': -1,
+ 'Buttons': [
+ 'UP',
+ 'DOWN',
+ 'LEFT',
+ 'RIGHT',
+ 'a',
+ 's',
+ 'd',
+ 'z',
+ 'x',
+ 'c',
+ 'RETURN',
+ 'f',
+ 'v',
+ 'q',
+ ],
+ },
+ {
+ 'Joystick': -1,
+ 'Buttons': [
+ 'KP_8',
+ 'KP_5',
+ 'KP_4',
+ 'KP_6',
+ 'p',
+ 'LBRACKET',
+ 'RBRACKET',
+ 'SEMICOLON',
+ 'QUOTE',
+ 'BACKSLASH',
+ 'SLASH',
+ 'o',
+ 'l',
+ 'PERIOD',
+ ],
+ },
+ {'Joystick': -1, 'Buttons': list(_UNUSED_BUTTONS)},
+ {'Joystick': -1, 'Buttons': list(_UNUSED_BUTTONS)},
+]
+
+_JOY_MAPPING: Final[list[_PadConfig]] = [
+ {'Joystick': 0, 'Buttons': list(_UNUSED_BUTTONS)},
+ {'Joystick': 1, 'Buttons': list(_UNUSED_BUTTONS)},
+ {'Joystick': 2, 'Buttons': list(_UNUSED_BUTTONS)},
+ {'Joystick': 3, 'Buttons': list(_UNUSED_BUTTONS)},
+]
+
+
+@cached_dataclass
+class Ikemen(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'ikemen',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4'], 'menu': 'KEY_ESC'},
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.rom
+
+ async def configure(self) -> Command:
+ config_path = self.rom / 'save' / 'config.json'
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+
+ try:
+ conf: dict[str, object] = json.loads(config_path.read_text())
+ except OSError, json.JSONDecodeError:
+ conf = {}
+
+ # Joystick configuration seems completely broken in 0.98.2 Linux
+ # so let's force keyboard and use a pad2key
+ conf['KeyConfig'] = _KEY_MAPPING
+ conf['JoystickConfig'] = _JOY_MAPPING
+ conf['Fullscreen'] = True
+
+ config_path.write_text(json.dumps(conf, indent=2))
+
+ return Command(
+ ['/usr/bin/ikemen'],
+ env={'MESA_GL_VERSION_OVERRIDE': '2.1'},
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/ioquake3.py b/python-src/batocera-launch/batocera_launch/emulators/ioquake3.py
new file mode 100644
index 00000000000..0b3dedaad66
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/ioquake3.py
@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+import shutil
+import stat
+from pathlib import Path
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+_SOURCE_DIR: Final = Path('/usr/bin/ioquake3')
+
+# basic controller config
+_CONTROLS: Final = (
+ 'bind PAD0_A "+moveup"\n',
+ 'bind PAD0_X "+movedown"\n',
+ 'bind PAD0_Y "+button2"\n',
+ 'bind PAD0_LEFTSHOULDER "weapnext"\n',
+ 'bind PAD0_RIGHTSHOULDER "weapprev"\n',
+ 'bind PAD0_LEFTSTICK_LEFT "+moveleft"\n',
+ 'bind PAD0_LEFTSTICK_RIGHT "+moveright"\n',
+ 'bind PAD0_LEFTSTICK_UP "+forward"\n',
+ 'bind PAD0_LEFTSTICK_DOWN "+back"\n',
+ 'bind PAD0_RIGHTSTICK_LEFT "+left"\n',
+ 'bind PAD0_RIGHTSTICK_RIGHT "+right"\n',
+ 'bind PAD0_RIGHTSTICK_UP "+lookup"\n',
+ 'bind PAD0_RIGHTSTICK_DOWN "+lookdown"\n',
+ 'bind PAD0_LEFTTRIGGER "+speed"\n',
+ 'bind PAD0_RIGHTTRIGGER "+attack"\n',
+)
+
+
+@cached_dataclass
+class IOQuake3(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'ioquake3',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': ['KEY_LEFTSHIFT', 'KEY_ESC'],
+ },
+ }
+
+ @cached_property
+ def roms_dir(self) -> Path:
+ return ROMS / 'quake3'
+
+ @cached_property
+ def config_dir(self) -> Path:
+ # Shared by ioquake3 and vkquake3 entry points.
+ return CONFIGS / 'ioquake3'
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.resolution.width / self.resolution.height > ((16.0 / 9.0) - 0.1) else 4 / 3
+
+ def _write_cfg_file(
+ self,
+ filename: Path,
+ init_line: str,
+ defaults: Iterable[str],
+ controls: Iterable[str],
+ /,
+ ) -> None:
+ if not filename.is_file():
+ filename.parent.mkdir(parents=True, exist_ok=True)
+ with filename.open('w') as file:
+ file.write(init_line)
+ file.writelines(defaults)
+ file.write(f'seta com_hunkMegs "{self.config.get_str("ioquake3_mem", "256")}"\n')
+ if self.core == 'vkquake3':
+ file.write(f'seta cl_renderer "{self.config.get_str("vkquake3_api", "opengl2")}"\n')
+ file.writelines(controls)
+ return
+
+ with filename.open('r+') as file:
+ lines = file.readlines()
+ file.seek(0)
+ file.truncate()
+ for line in lines:
+ ## Set defaults every time
+ # resolution
+ if line.startswith('seta r_mode'):
+ line = 'seta r_mode "-1"\n'
+ elif line.startswith('seta r_customwidth'):
+ line = f'seta r_customwidth "{self.resolution.width}"\n'
+ elif line.startswith('seta r_customheight'):
+ line = f'seta r_customheight "{self.resolution.height}"\n'
+ # controllers
+ elif line.startswith('seta in_joystickUseAnalog'):
+ line = 'seta in_joystickUseAnalog "1"\n'
+ elif line.startswith('seta in_joystick'):
+ line = 'seta in_joystick "1"\n'
+ # network downloads
+ elif line.startswith('seta cl_allowDownload'):
+ line = 'seta cl_allowDownload "1"\n'
+ ## User options
+ # Memory
+ elif line.startswith('seta com_hunkMegs'):
+ line = f'seta com_hunkMegs "{self.config.get_str("ioquake3_mem", "256")}"\n'
+ # API
+ elif line.startswith('seta cl_renderer'):
+ if self.core == 'vkquake3':
+ line = f'seta cl_renderer "{self.config.get_str("vkquake3_api", "opengl2")}"\n'
+ else:
+ # ioquake3 doesn't use this, so remove it if a vkquake3 setting gets added
+ continue
+
+ file.write(line)
+
+ # Add the missing lines at the end of the file
+ for line in defaults:
+ if line not in lines:
+ file.write(line)
+ for line in controls:
+ if line not in lines:
+ file.write(line)
+
+ def _write_cfg_files(self) -> None:
+ # create the cfg files for each quake3 rom / mod folder
+ # minimum defaults
+ defaults = [
+ 'seta r_mode "-1"\n',
+ f'seta r_customwidth "{self.resolution.width}"\n',
+ f'seta r_customheight "{self.resolution.height}"\n',
+ 'seta in_joystickUseAnalog "1"\n',
+ 'seta in_joystick "1"\n',
+ 'seta cl_allowDownload "1"\n',
+ ]
+
+ if not self.roms_dir.is_dir():
+ return
+
+ # get the immediate subdirectories within rom directory
+ for subdirectory in (path.name for path in self.roms_dir.iterdir() if path.is_dir()):
+ self._write_cfg_file(
+ self.config_dir / subdirectory / 'q3config.cfg',
+ '// generated by quake, do not modify\n',
+ defaults,
+ _CONTROLS,
+ )
+
+ async def configure(self) -> Command:
+ self._write_cfg_files()
+
+ # ioquake3 looks for folder either in config or from where it's launched
+ binary = self.roms_dir / 'ioquake3'
+ source_file = _SOURCE_DIR / 'ioquake3'
+
+ # therefore copy latest ioquake3 file to rom directory
+ if not binary.is_file() or source_file.stat().st_mtime > binary.stat().st_mtime:
+ shutil.copytree(_SOURCE_DIR, self.roms_dir, dirs_exist_ok=True)
+
+ # Mark the copied executable file as executable (chmod +x)
+ if binary.is_file():
+ binary.chmod(binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+
+ # get the game / mod to launch
+ command_line = self.rom.read_text().splitlines()[0].strip().split()
+ return Command([binary, *command_line])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/iortcw.py b/python-src/batocera-launch/batocera_launch/emulators/iortcw.py
new file mode 100644
index 00000000000..d1376726f42
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/iortcw.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class IORTCW(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'iortcw',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F5',
+ 'restore_state': 'KEY_F9',
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ @cached_property
+ def config_file(self) -> Path:
+ return self.roms_dir / 'main' / 'wolfconfig.cfg'
+
+ async def configure(self) -> Command:
+ self.config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ filtering = self.config.get_str('iortcw_filtering', '2')
+ aa = self.config.get_str('iortcw_aa', '0')
+
+ options_to_set = {
+ 'seta r_mode': '-1',
+ 'seta r_noborder': '1',
+ 'seta r_fullscreen': '1',
+ 'seta r_allowResize': '0',
+ 'seta r_centerWindow': '1',
+ 'seta r_inGameVideo': '1',
+ 'seta r_customheight': str(self.resolution.height),
+ 'seta r_customwidth': str(self.resolution.width),
+ 'seta in_joystick': '1',
+ 'seta in_joystickUseAnalog': '1',
+ 'bind PAD0_A': '+moveup',
+ 'bind PAD0_X': '+movedown',
+ 'bind PAD0_Y': '+useitem',
+ 'bind PAD0_B': '+activate',
+ 'bind PAD0_LEFTSHOULDER': 'weapnext',
+ 'bind PAD0_RIGHTSHOULDER': 'weapprev',
+ 'bind PAD0_LEFTSTICK_LEFT': '+moveleft',
+ 'bind PAD0_LEFTSTICK_RIGHT': '+moveright',
+ 'bind PAD0_LEFTSTICK_UP': '+forward',
+ 'bind PAD0_LEFTSTICK_DOWN': '+back',
+ 'bind PAD0_RIGHTSTICK_LEFT': '+left',
+ 'bind PAD0_RIGHTSTICK_RIGHT': '+right',
+ 'bind PAD0_RIGHTSTICK_UP': '+lookup',
+ 'bind PAD0_RIGHTSTICK_DOWN': '+lookdown',
+ 'bind PAD0_LEFTTRIGGER': '+speed',
+ 'bind PAD0_RIGHTTRIGGER': '+attack',
+ 'seta cl_renderer': self.config.get_str('iortcw_api', 'opengl1'),
+ 'seta r_swapInterval': self.config.get_bool('iortcw_vsync', return_values=('1', '0')),
+ 'seta com_maxfps': self.config.get_str('iortcw_fps', '60'),
+ 'seta r_ext_texture_filter_anisotropic': '0' if filtering == '2' else '1',
+ 'seta r_ext_max_anisotropy': filtering,
+ 'seta r_ext_multisample': aa,
+ 'seta r_ext_framebuffer_multisample': aa,
+ 'seta com_introplayed': self.config.get_bool('iortcw_skip_video', return_values=('1', '0')),
+ 'seta cl_language': self.config.get_str('iortcw_language', '0'),
+ }
+
+ self._update_config_file(options_to_set)
+
+ # iortcw looks for roms in home + /iortcw
+ return Command(
+ ['/usr/bin/iortcw/iowolfsp'],
+ env={'XDG_DATA_HOME': ROMS},
+ )
+
+ def _update_config_file(self, options_to_set: dict[str, str], /) -> None:
+ if self.config_file.is_file():
+ lines = self.config_file.read_text(encoding='utf-8').splitlines(keepends=True)
+
+ for key, value in options_to_set.items():
+ option_line = f'{key} "{value}"\n'
+ if any(key in line for line in lines):
+ lines = [option_line if key in line else line for line in lines]
+ else:
+ lines.append(option_line)
+
+ self.config_file.write_text(''.join(lines), encoding='utf-8')
+ else:
+ self.config_file.write_text(
+ ''.join(f'{key} "{value}"\n' for key, value in options_to_set.items()),
+ encoding='utf-8',
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/jazz2_native.py b/python-src/batocera-launch/batocera_launch/emulators/jazz2_native.py
new file mode 100644
index 00000000000..98e6cd2563d
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/jazz2_native.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CACHE, CONFIGS, ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Jazz2_Native(Emulator):
+ needs_sdl_game_controller_config = True
+ needs_sdl_controller_db = True
+ sdl_controller_db_path = Path('/usr/share/jazz2/gamecontrollerdb.txt')
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'jazz2',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ return Command(
+ ['jazz2'],
+ env={
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_CACHE_HOME': CACHE,
+ 'XDG_DATA_HOME': ROMS / 'jazz2',
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/lexaloffle.py b/python-src/batocera-launch/batocera_launch/emulators/lexaloffle.py
new file mode 100644
index 00000000000..886ae4a431b
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/lexaloffle.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING, Final, NamedTuple
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS, HOME, ROMS, SCREENSHOTS
+
+from ..command import Command
+from ..emulator import Emulator
+from ..exceptions import BatoceraException
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from ..types import HotkeysContext
+
+
+class _LexalofflePaths(NamedTuple):
+ ld_lib: Path
+ binary: Path
+ controllers: Path
+ root: Path
+
+
+_SYSTEMS: Final = {
+ 'pico8': _LexalofflePaths(
+ BIOS / 'pico-8',
+ BIOS / 'pico-8' / 'pico8',
+ HOME / '.lexaloffle' / 'pico-8' / 'sdl_controllers.txt',
+ ROMS / 'pico8',
+ ),
+ 'voxatron': _LexalofflePaths(
+ BIOS / 'voxatron',
+ BIOS / 'voxatron' / 'vox',
+ HOME / '.lexaloffle' / 'Voxatron' / 'sdl_controllers.txt',
+ ROMS / 'voxatron',
+ ),
+}
+
+
+@cached_dataclass
+class Lexaloffle(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'lexaloffle',
+ 'keys': {
+ 'exit': ['KEY_LEFTCTRL', 'KEY_Q'],
+ 'menu': 'KEY_ENTER',
+ 'reset': ['KEY_LEFTCTRL', 'KEY_R'],
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 4 / 3
+
+ async def configure(self) -> Command:
+ if (paths := _SYSTEMS.get(self.system)) is None:
+ raise BatoceraException(f'The Lexaloffle generator has been called for an unknown system: {self.system}.')
+
+ if not paths.binary.exists():
+ raise BatoceraException(f'Lexaloffle official binary not found at {paths.binary}')
+
+ if not os.access(paths.binary, os.X_OK):
+ raise BatoceraException(f'{paths.binary} is not set as executable')
+
+ args: list[str | Path] = [
+ paths.binary,
+ '-desktop',
+ SCREENSHOTS,
+ '-windowed',
+ '0',
+ '-show_fps',
+ '1' if self.config.show_fps else '0',
+ ]
+
+ cart = self.rom
+ if cart.suffix.lower() == '.m3u':
+ cart = cart.parent / cart.read_text().splitlines()[0].strip()
+ args.extend(['-root_path', cart.parent])
+ else:
+ args.extend(['-root_path', paths.root])
+
+ if self.rom.stem.lower() in {'splore', 'console'}:
+ args.append('-splore')
+ else:
+ args.extend(['-run', cart])
+
+ paths.controllers.parent.mkdir(parents=True, exist_ok=True)
+ paths.controllers.write_text(self.get_sdl_game_controller_config())
+
+ ld_library_path: str | Path = paths.ld_lib
+ if existing_library_path := os.environ.get('LD_LIBRARY_PATH'):
+ ld_library_path = f'{paths.ld_lib}:{existing_library_path}'
+
+ return Command(
+ args,
+ env={
+ 'SDL_AUDIODRIVER': 'alsa',
+ 'LD_LIBRARY_PATH': ld_library_path,
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/lightspark.py b/python-src/batocera-launch/batocera_launch/emulators/lightspark.py
new file mode 100644
index 00000000000..31a0daa1ff6
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/lightspark.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Lightspark(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'lightspark',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ async def configure(self) -> Command:
+ return Command(['lightspark', '-fs', '-s', 'local-with-networking', self.rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/nanoboyadvance.py b/python-src/batocera-launch/batocera_launch/emulators/nanoboyadvance.py
new file mode 100644
index 00000000000..8c443f3d91c
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/nanoboyadvance.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS, CONFIGS, SAVES
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+# NanoBoyAdvance reads $XDG_CONFIG_HOME/NanoBoyAdvance/config.toml
+# Point XDG_CONFIG_HOME to CONFIGS; the app appends NanoBoyAdvance/ itself.
+
+_GBA_BIOS: Final = BIOS / 'gba_bios.bin'
+
+# Default Qt key codes for keyboard fallback (used with evmapy pad-to-key)
+# SDFG home-row mapping: same physical position on all keyboard layouts (QWERTY, AZERTY, QWERTZ...)
+_DEFAULT_KEYBOARD: Final[dict[str, int]] = {
+ 'a': 83, # Qt::Key_S
+ 'b': 68, # Qt::Key_D
+ 'select': 16777219, # Qt::Key_Backspace
+ 'start': 16777220, # Qt::Key_Return
+ 'right': 16777236, # Qt::Key_Right
+ 'left': 16777234, # Qt::Key_Left
+ 'up': 16777235, # Qt::Key_Up
+ 'down': 16777237, # Qt::Key_Down
+ 'r': 71, # Qt::Key_G
+ 'l': 70, # Qt::Key_F
+}
+
+
+def _bool(value: bool) -> str:
+ return 'true' if value else 'false'
+
+
+def _quoted(s: str) -> str:
+ return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
+
+
+def _map(gba_key: str) -> str:
+ kb = _DEFAULT_KEYBOARD[gba_key]
+ return f'{gba_key} = [{kb}, -1, -1, -1, 0]'
+
+
+def _build_toml(
+ *,
+ bios_path: str,
+ bios_skip: bool,
+ save_folder: str,
+ filter_val: str,
+ color_correction: str,
+ lcd_ghosting: bool,
+ resampler: str,
+ mp2k_hle: bool,
+ integer_scaling: bool,
+ force_solar_sensor: bool,
+ solar_sensor_level: int,
+) -> str:
+ return f"""\
+[general]
+bios_path = {_quoted(bios_path)}
+bios_skip = {_bool(bios_skip)}
+save_folder = {_quoted(save_folder)}
+fast_forward_speed = 2
+
+[cartridge]
+save_type = "detect"
+force_rtc = true
+force_solar_sensor = {_bool(force_solar_sensor)}
+solar_sensor_level = {solar_sensor_level}
+
+[video]
+filter = {_quoted(filter_val)}
+color_correction = {_quoted(color_correction)}
+lcd_ghosting = {_bool(lcd_ghosting)}
+
+[audio]
+resampler = {_quoted(resampler)}
+mp2k_hle_enable = {_bool(mp2k_hle)}
+mp2k_hle_cubic = true
+mp2k_hle_force_reverb = true
+
+[window]
+fullscreen = true
+fullscreen_show_menu = false
+scale = 2
+maximum_scale = 0
+show_fps = false
+lock_aspect_ratio = true
+use_integer_scaling = {_bool(integer_scaling)}
+pause_emulator_when_inactive = false
+
+[input]
+hold_fast_forward = true
+fast_forward = [32, -1, -1, -1, 0]
+controller_guid = ""
+[input.gba]
+{_map('a')}
+{_map('b')}
+{_map('select')}
+{_map('start')}
+{_map('right')}
+{_map('left')}
+{_map('up')}
+{_map('down')}
+{_map('r')}
+{_map('l')}
+"""
+
+
+@cached_dataclass
+class Nanoboyadvance(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'nanoboyadvance',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': ['KEY_LEFTSHIFT', 'KEY_F1'],
+ 'restore_state': 'KEY_F1',
+ },
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'NanoBoyAdvance'
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / 'nanoboyadvance'
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 3 / 2
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ (self.config_dir / 'config.toml').write_text(
+ _build_toml(
+ bios_path=str(_GBA_BIOS),
+ bios_skip=self.config.get_bool('nba_skip_bios'),
+ save_folder=str(self.saves_dir),
+ filter_val=self.config.get_str('nba_filter', 'nearest'),
+ color_correction=self.config.get_str('nba_color_correction', 'agb'),
+ lcd_ghosting=self.config.get_bool('nba_lcd_ghosting', True),
+ resampler=self.config.get_str('nba_audio_resampler', 'cosine'),
+ mp2k_hle=self.config.get_bool('nba_mp2k_hle'),
+ integer_scaling=self.config.get_bool('nba_integer_scaling'),
+ force_solar_sensor=self.config.get_bool('nba_force_solar_sensor'),
+ solar_sensor_level=self.config.get_int('nba_solar_sensor_level', 23),
+ ),
+ )
+
+ # NanoBoyAdvance supports archives natively via unarr (zip, 7z, tar, rar1).
+ # Pass the path as-is — no extraction needed.
+ return Command(
+ ['/usr/bin/NanoBoyAdvance', self.rom],
+ env={
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'QT_QPA_PLATFORM': 'xcb',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/odcommander.py b/python-src/batocera-launch/batocera_launch/emulators/odcommander.py
new file mode 100644
index 00000000000..5b66c1f5ebc
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/odcommander.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class ODCommander(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'odcommander',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ return Command(['od-commander'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/pygame.py b/python-src/batocera-launch/batocera_launch/emulators/pygame.py
new file mode 100644
index 00000000000..a9173db1a36
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/pygame.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class Pygame(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'pygame',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.rom.parent
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ return Command(['pygame', self.rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/pyxel.py b/python-src/batocera-launch/batocera_launch/emulators/pyxel.py
new file mode 100644
index 00000000000..44f253a8ca1
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/pyxel.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Pyxel(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'pyxel',
+ 'keys': {'exit': 'KEY_ESC'},
+ }
+
+ async def configure(self) -> Command:
+ cmd = 'play' if self.rom.suffix == '.pyxapp' else 'run'
+ return Command(['/usr/bin/pyxel', cmd, self.rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/raze.py b/python-src/batocera-launch/batocera_launch/emulators/raze.py
new file mode 100644
index 00000000000..714dfd510c9
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/raze.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+import logging
+import platform
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import (
+ Command,
+ Emulator,
+ HotkeysContext,
+ parse_build_engine_args,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+_GAME_NAMES: Final = (
+ 'Blood',
+ 'Duke',
+ 'Exhumed',
+ 'Nam',
+ 'Redneck',
+ 'ShadowWarrior',
+ 'WW2GI',
+)
+
+_CONSOLE_DEFAULTS: Final = {
+ 'hud_size': 8,
+ 'm_sensitivity_x': 6.0,
+ 'm_sensitivity_y': 5.5,
+}
+
+_BINDING_DEFAULTS: Final = {
+ 'F6': 'quicksave',
+ 'F9': 'quickload',
+ 'F12': 'screenshot',
+ 'C': 'toggleconsole',
+ 'Tab': 'togglemap',
+ 'E': '+Move_Forward',
+ 'D': '+Move_Backward',
+ 'S': '+Strafe_Left',
+ 'F': '+Strafe_Right',
+ 'PgUp': '+Quick_Kick',
+ 'PgDn': '+Alt_Fire',
+ 'Del': 'toggle cl_autorun',
+ 'Ins': '+toggle_crouch',
+ 'UpArrow': 'weapprev',
+ 'DownArrow': 'weapnext',
+ 'LeftArrow': 'invprev',
+ 'RightArrow': 'invnext',
+ 'X': 'invuse',
+ 'B': '+jump',
+ 'Y': '+open',
+ 'A': '+open',
+ 'Pad_Start': 'menu_endgame',
+ 'Pad_Back': 'togglemap',
+ 'Pad_A': '+open',
+ 'Pad_Y': '+jump',
+ 'Pad_B': '+Quick_Kick',
+ 'Pad_X': '+crouch',
+ 'LThumb': '+Run',
+ 'RThumb': '+toggle_crouch',
+ 'LShoulder': 'weapprev',
+ 'RShoulder': 'weapnext',
+ 'LTrigger': '+altattack',
+ 'RTrigger': '+fire',
+ 'DPadDown': 'invuse',
+ 'DPadLeft': 'invprev',
+ 'DPadRight': 'invnext',
+}
+
+_AUTOMAP_BINDING_DEFAULTS: Final = {
+ 'PgUp': '+Shrink_Screen',
+ 'PgDn': '+Enlarge_Screen',
+ 'UpArrow': '+am_panup',
+ 'DownArrow': '+am_pandown',
+ 'LeftArrow': '+am_panleft',
+ 'RightArrow': '+am_panright',
+ 'Del': 'togglefollow',
+ 'Ins': 'togglerotate',
+ 'LThumb': '+enlarge_Screen',
+ 'RThumb': '+shrink_screen',
+}
+
+_INTEL_ARCHES: Final = frozenset({'x86_64', 'amd64', 'i686', 'i386'})
+
+
+def _build_config_defaults() -> dict[str, dict[str, object]]:
+ # Raze does not support global bindings; set defaults per game series (first boot only).
+ defaults: dict[str, dict[str, object]] = {}
+ for name in _GAME_NAMES:
+ defaults[f'{name}.ConsoleVariables'] = dict(_CONSOLE_DEFAULTS)
+ defaults[f'{name}.Bindings'] = dict(_BINDING_DEFAULTS)
+ defaults[f'{name}.AutomapBindings'] = dict(_AUTOMAP_BINDING_DEFAULTS)
+ return defaults
+
+
+_CONFIG_DEFAULTS: Final = _build_config_defaults()
+
+
+def _gl_es_line(raze_api: str | None, architecture: str, current: str, /) -> str:
+ if raze_api and raze_api != '2':
+ if raze_api == '0':
+ value = 'false' if architecture in _INTEL_ARCHES else 'true'
+ return f'gl_es={value}\n'
+ return current
+ return 'gl_es=true\n'
+
+
+@cached_dataclass
+class Raze(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'raze',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': 'KEY_F6',
+ 'restore_state': 'KEY_F9',
+ 'screenshot': 'KEY_F12',
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ architecture = platform.uname().machine
+ _logger.debug('*** Detected architecture is: %s ***', architecture)
+
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ config_file = self.config_dir / 'raze.ini'
+ script_file = self.config_dir / 'raze.cfg'
+
+ if not config_file.exists():
+ sections: list[str] = []
+ for section, values in _CONFIG_DEFAULTS.items():
+ sections.append(f'[{section}]')
+ sections.extend(f'{key}={value}' for key, value in values.items())
+ sections.append('')
+ config_file.write_text('\n'.join(sections))
+
+ config_backup = config_file.read_text().splitlines(keepends=True)
+ raze_api = self.config.get_str('raze_api')
+
+ with config_file.open('w') as out:
+ global_settings_found = False
+ for line in config_backup:
+ if line.strip() == '[GlobalSettings]':
+ global_settings_found = True
+
+ if global_settings_found:
+ stripped = line.strip()
+ if stripped.startswith('gl_es='):
+ line = _gl_es_line(raze_api, architecture, line)
+ elif stripped.startswith('vid_preferbackend='):
+ line = f'vid_preferbackend={raze_api or "2"}\n'
+ elif stripped.startswith('use_joystick='):
+ line = 'use_joystick=true\n'
+
+ out.write(line)
+
+ if not global_settings_found:
+ _logger.debug('Global Settings NOT found')
+ out.write('[GlobalSettings]\n')
+ # Preserve original first-boot quirk: gl_es is only derived when api is OpenGL.
+ if raze_api == '0':
+ if architecture in _INTEL_ARCHES:
+ _logger.debug('*** Architecture is intel; gl_es=false ***')
+ else:
+ _logger.debug(
+ '*** Architecture is not intel (%s); gl_es=true ***',
+ architecture,
+ )
+ out.write(f'vid_preferbackend={raze_api or "2"}\n')
+ out.write('use_joystick=true\n')
+
+ script_file.write_text(
+ '\n'.join(
+ [
+ '# This file is automatically generated by batocera-launch',
+ f'vid_fps {"true" if self.config.show_fps else "false"}',
+ 'echo BATOCERA',
+ '',
+ ]
+ )
+ )
+
+ args: list[str | Path] = ['raze', *parse_build_engine_args(self.rom)]
+ args.extend(
+ [
+ '-exec',
+ script_file,
+ '-width',
+ str(self.resolution.width),
+ '-height',
+ str(self.resolution.height),
+ ]
+ )
+ if self.config.get_bool('nologo'):
+ args.append('-nologo')
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/redream.py b/python-src/batocera-launch/batocera_launch/emulators/redream.py
new file mode 100644
index 00000000000..1cdcdc8fad9
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/redream.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import filecmp
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_BINARY_SRC: Final = Path('/usr/bin/redream')
+
+_BUTTON_MAP: Final = {
+ 'a': 'b',
+ 'b': 'a',
+ 'x': 'y',
+ 'y': 'x',
+ 'start': 'start',
+ 'select': 'menu',
+ 'pageup': 'turbo',
+}
+_HAT_MAP: Final = {'up': 0, 'down': 1, 'left': 2, 'right': 3}
+_AXIS_MAP: Final = {
+ 'joystick1left': 0,
+ 'joystick1up': 1,
+ # use input.id for l2/r2
+ 'l2': 2,
+ 'r2': 3,
+}
+_NINTENDO_GUID: Final = '030000007e0500000920000011810000'
+_NINTENDO_PROFILE: Final = (
+ 'b:joy1,a:joy0,dpad_down:hat1,ljoy_left:-axis0,ljoy_right:+axis0,ljoy_up:-axis1,'
+ 'ljoy_down:+axis1,ltrig:joy6,dpad_left:hat2,rtrig:joy7,dpad_right:hat3,turbo:joy8,'
+ 'start:joy9,dpad_up:hat0,y:joy2,x:joy3,'
+)
+
+
+@cached_dataclass
+class Redream(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'redream',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @cached_property
+ def roms_dir(self) -> Path:
+ return ROMS / 'dreamcast'
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ ratio = self.config.get_str('redreamRatio')
+ return 16 / 9 if ratio in {'16:9', 'stretch'} else 4 / 3
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ redream_exec = self.config_dir / 'redream'
+
+ if not redream_exec.exists() or not filecmp.cmp(_BINARY_SRC, redream_exec):
+ shutil.copyfile(_BINARY_SRC, redream_exec)
+ redream_exec.chmod(0o0775)
+
+ lines: list[str] = []
+
+ # set the roms path
+ lines.append(f'gamedir={self.roms_dir}')
+ # force fullscreen
+ lines.append('mode=exclusive fullscreen')
+ lines.append('fullmode=exclusive fullscreen')
+
+ # configure controller
+ written_guids: set[str] = set()
+ for controller in self.controllers[:4]:
+ lines.append(f'port{controller.index}=dev:{4 + controller.index},desc:{controller.guid},type:controller')
+ ctrl_profile = f'profile{controller.index}=name:{controller.guid},type:controller,deadzone:12,crosshair:1,'
+ full_profile = ctrl_profile
+
+ for input in controller.inputs.values():
+ # [buttons]
+ if input.type == 'button' and input.name in _BUTTON_MAP:
+ full_profile += f'{_BUTTON_MAP[input.name]}:joy{input.id},'
+ # on rare occassions when triggers are buttons
+ if input.type == 'button' and input.name == 'l2':
+ full_profile += f'ltrig:joy{input.id},'
+ if input.type == 'button' and input.name == 'r2':
+ full_profile += f'rtrig:joy{input.id},'
+ # on occassions when dpad directions are buttons
+ if input.type == 'button' and input.name in {'up', 'down', 'left', 'right'}:
+ full_profile += f'dpad_{input.name}:joy{input.id},'
+ # [hats]
+ if input.type == 'hat' and input.name in _HAT_MAP:
+ full_profile += f'dpad_{input.name}:hat{_HAT_MAP[input.name]},'
+ # [axis]
+ if input.type == 'axis' and input.name in _AXIS_MAP:
+ axis_id = _AXIS_MAP[input.name]
+ # l2/r2 as axis triggers
+ if input.name == 'l2':
+ full_profile += f'ltrig:+axis{input.id},'
+ if input.name == 'r2':
+ full_profile += f'rtrig:+axis{input.id},'
+ # handle axis l,r,u,d
+ if input.name == 'joystick1left':
+ full_profile += f'ljoy_left:-axis{axis_id},'
+ full_profile += f'ljoy_right:+axis{axis_id},'
+ if input.name == 'joystick1up':
+ full_profile += f'ljoy_up:-axis{axis_id},'
+ full_profile += f'ljoy_down:+axis{axis_id},'
+
+ # special nintendo workaround since redream makes no sense...
+ if controller.guid == _NINTENDO_GUID:
+ full_profile = ctrl_profile + _NINTENDO_PROFILE
+ # add key to exit for evmapy to the end
+ full_profile += 'exit:f10'
+ # check if we have already writtent the profile, if so, we don't save it
+ if controller.guid not in written_guids:
+ written_guids.add(controller.guid)
+ lines.append(full_profile)
+
+ # change settings as per users options
+ # [video]
+ lines.extend(
+ [
+ f'width={self.resolution.width}',
+ f'height={self.resolution.height}',
+ f'fullwidth={self.resolution.width}',
+ f'fullheight={self.resolution.height}',
+ f'res={self.config.get_str("redreamResolution", "2")}',
+ f'aspect={self.config.get_str("redreamRatio", "4:3")}',
+ f'frameskip={self.config.get_str("redreamFrameSkip", "0")}',
+ f'vsync={self.config.get_str("redreamVsync", "0")}',
+ f'renderer={self.config.get_str("redreamRender", "hle_perstrip")}',
+ ]
+ )
+ # [system]
+ lines.extend(
+ [
+ f'region={self.config.get_str("redreamRegion", "usa")}',
+ f'language={self.config.get_str("redreamLanguage", "english")}',
+ f'broadcast={self.config.get_str("redreamBroadcast", "ntsc")}',
+ f'cable={self.config.get_str("redreamCable", "vga")}',
+ ]
+ )
+
+ (self.config_dir / 'redream.cfg').write_text('\n'.join(lines) + '\n')
+
+ return Command(
+ [redream_exec, self.rom],
+ env={'SDL_JOYSTICK_HIDAPI': '0', 'SDL_AUDIODRIVER': 'alsa'},
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/ruffle.py b/python-src/batocera-launch/batocera_launch/emulators/ruffle.py
new file mode 100644
index 00000000000..3cce7d4f8c3
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/ruffle.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Ruffle(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'ruffle',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ async def configure(self) -> Command:
+ return Command(['ruffle', self.rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/samcoupe.py b/python-src/batocera-launch/batocera_launch/emulators/samcoupe.py
new file mode 100644
index 00000000000..4db99f072f9
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/samcoupe.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Samcoupe(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'samcoupe',
+ 'keys': {
+ 'exit': ['KEY_LEFTCTRL', 'KEY_F12'],
+ 'menu': 'KEY_F10',
+ 'pause': 'KEY_F10',
+ },
+ }
+
+ async def configure(self) -> Command:
+ return Command(['simcoupe', '-fullscreen', '1', self.rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/scummvm.py b/python-src/batocera-launch/batocera_launch/emulators/scummvm.py
new file mode 100644
index 00000000000..f3f8087d6b3
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/scummvm.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import os
+import re
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS, CACHE, CONFIGS, LOGS, SCREENSHOTS
+from batocera_launch import (
+ Command,
+ Controller,
+ Emulator,
+ HotkeysContext,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_EXTRA_DIR: Final = BIOS / 'scummvm' / 'extra'
+_GAME_ID_RE: Final = re.compile(r'^(?:[a-z0-9-]+:)?[a-z0-9-]+$')
+_ASPECT_STRETCH_MODES: Final = {'fit_force_aspect', 'pixel-perfect'}
+
+
+@cached_dataclass
+class ScummVM(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'scummvm',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': ['KEY_LEFTCTRL', 'KEY_F5'],
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 4 / 3 if self.config.get('scumm_stretch') in _ASPECT_STRETCH_MODES else 16 / 9
+
+ async def configure(self) -> Command:
+ _EXTRA_DIR.mkdir(parents=True, exist_ok=True)
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ config_file = self.config_dir / 'scummvm.ini'
+ config = CaseSensitiveConfigParser(interpolation=None)
+ if config_file.exists():
+ config.read(config_file)
+
+ if not config.has_section('scummvm'):
+ config.add_section('scummvm')
+ config.set('scummvm', 'gui_browser_native', 'false')
+
+ with config_file.open('w') as file:
+ config.write(file)
+
+ # 1. If a .scummvm file exists and contains a valid , use the
+ # 2. If an empty .scummvm file exists, use the
+ # 3. Otherwise, auto detect the game
+ if self.rom.is_dir():
+ # squashfs: find a .scummvm file
+ rom_file = next(self.rom.glob('*.scummvm'), None)
+ rom_path = self.rom
+ else:
+ rom_file = self.rom
+ rom_path = self.rom.parent
+
+ target = '--auto-detect'
+ if rom_file is not None:
+ game_id = rom_file.read_text().strip().lower() or rom_file.stem
+ if _GAME_ID_RE.match(game_id) is not None:
+ target = game_id
+
+ joystick_id = 0
+ if pad := Controller.find_player_number(self.controllers, 1):
+ joystick_id = pad.index
+
+ args: list[str | Path] = [
+ '/usr/bin/scummvm',
+ '-f',
+ f'--window-size={self.resolution.width},{self.resolution.height}',
+ f'--scale-factor={self.config.get_str("scumm_scale", "3")}',
+ f'--scaler={self.config.get_str("scumm_scaler_mode", "normal")}',
+ ]
+
+ if stretch := self.config.get_str('scumm_stretch'):
+ args.append(f'--stretch-mode={stretch}')
+
+ args.append(f'--renderer={self.config.get_str("scumm_renderer", "opengl")}')
+
+ if language := self.config.get_str('scumm_language'):
+ args.extend(['-q', language])
+
+ args.extend(
+ [
+ f'--logfile={LOGS / "scummvm.log"}',
+ f'--joystick={joystick_id}',
+ f'--screenshotspath={SCREENSHOTS}',
+ f'--extrapath={_EXTRA_DIR}',
+ f'--savepath={self.saves_dir}',
+ f'--path={rom_path}',
+ target,
+ ]
+ )
+
+ return Command(
+ args,
+ env={
+ 'SDL_VIDEODRIVER': 'wayland' if 'WAYLAND_DISPLAY' in os.environ else 'x11',
+ 'XDG_CONFIG_HOME': CONFIGS,
+ 'XDG_CACHE_HOME': CACHE,
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/sdlpop.py b/python-src/batocera-launch/batocera_launch/emulators/sdlpop.py
new file mode 100644
index 00000000000..d1967c7f6ab
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/sdlpop.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import SCREENSHOTS
+from batocera_launch import Command, Controller, Emulator, HotkeysContext
+
+_SYSTEM_DIR: Final = Path('/usr/share/sdlpop')
+_SCREENSHOTS_DIR: Final = SCREENSHOTS / 'sdlpop'
+
+
+@cached_dataclass
+class SdlPop(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'sdlpop',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F6',
+ 'restore_state': 'KEY_F9',
+ 'reset': ['KEY_LEFTCTRL', 'KEY_R'],
+ },
+ }
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+
+ user_config = self.config_dir / 'SDLPoP.cfg'
+ user_ini = self.config_dir / 'SDLPoP.ini'
+ system_cfg = _SYSTEM_DIR / 'cfg' / 'SDLPoP.cfg'
+ system_ini_src = _SYSTEM_DIR / 'cfg' / 'SDLPoP.ini'
+
+ if not user_config.exists():
+ shutil.copyfile(system_cfg, user_config)
+ if not user_ini.exists():
+ shutil.copyfile(system_ini_src, user_ini)
+
+ system_config = _SYSTEM_DIR / 'SDLPoP.cfg'
+ system_ini = _SYSTEM_DIR / 'SDLPoP.ini'
+ if not system_config.exists():
+ system_config.symlink_to(user_config)
+ if not system_ini.exists():
+ system_ini.symlink_to(user_ini)
+
+ if not _SCREENSHOTS_DIR.exists():
+ _SCREENSHOTS_DIR.mkdir(parents=True)
+ (_SYSTEM_DIR / 'screenshots').symlink_to(_SCREENSHOTS_DIR, target_is_directory=True)
+
+ args: list[str | Path] = ['SDLPoP']
+ if pad := Controller.find_player_number(self.controllers, 1):
+ args.append(f'joynum={pad.index}')
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/sh.py b/python-src/batocera-launch/batocera_launch/emulators/sh.py
new file mode 100644
index 00000000000..c67f3003d5b
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/sh.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+
+from ..command import Command
+from ..emulator import Emulator
+
+if TYPE_CHECKING:
+ from ..types import HotkeysContext
+
+
+@cached_dataclass
+class Sh(Emulator):
+ needs_sdl_game_controller_config = True
+ needs_sdl_controller_db = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'shell',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ async def configure(self) -> Command:
+ # in case of squashfs, the root directory is passed
+ run_sh = self.rom / 'run.sh'
+ rom = run_sh if run_sh.exists() else self.rom
+ return Command(['/bin/bash', rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/solarus.py b/python-src/batocera-launch/batocera_launch/emulators/solarus.py
new file mode 100644
index 00000000000..90650f4a82f
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/solarus.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Controller, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from batocera_launch import Controllers, Input
+
+_KEYMAPPING: Final = {
+ 'action': 'a',
+ 'attack': 'b',
+ 'item1': 'y',
+ 'item2': 'x',
+ 'pause': 'start',
+ 'right': 'right',
+ 'up': 'up',
+ 'left': 'left',
+ 'down': 'down',
+}
+
+_REVERSE_AXIS: Final = {
+ 'up': 'down',
+ 'left': 'right',
+}
+
+
+def _key2val(input: Input, reverse: bool, /) -> str | None:
+ if input.type == 'button':
+ return f'button {input.id}'
+ if input.type == 'hat':
+ if input.value == '1':
+ return 'hat 0 up'
+ if input.value == '2':
+ return 'hat 0 right'
+ if input.value == '4':
+ return 'hat 0 down'
+ if input.value == '8':
+ return 'hat 0 left'
+ return None
+ if input.type == 'axis':
+ if (reverse and input.value == '-1') or (not reverse and input.value == '1'):
+ return f'axis {input.id} +'
+ return f'axis {input.id} -'
+ return None
+
+
+def _write_pad_config(config_dir: Path, controllers: Controllers, joystick: str | None, /) -> None:
+ keymapping = dict(_KEYMAPPING)
+ if joystick in {'joystick1', 'joystick2'}:
+ keymapping['up'] = f'{joystick}up'
+ keymapping['down'] = f'{joystick}down'
+ keymapping['left'] = f'{joystick}left'
+ keymapping['right'] = f'{joystick}right'
+
+ config_dir.mkdir(parents=True, exist_ok=True)
+ with (config_dir / 'pads.ini').open('w', encoding='ascii') as f:
+ if pad := Controller.find_player_number(controllers, 1):
+ for key, mapped in keymapping.items():
+ if mapped not in pad.inputs:
+ continue
+ if (value := _key2val(pad.inputs[mapped], False)) is not None:
+ f.write(f'{key}={value}\n')
+ if (
+ key in _REVERSE_AXIS
+ and pad.inputs[mapped].type == 'axis'
+ and (value := _key2val(pad.inputs[mapped], True)) is not None
+ ):
+ f.write(f'{_REVERSE_AXIS[key]}={value}\n')
+
+
+@cached_dataclass
+class Solarus(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'solarus',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ args: list[str | Path] = ['solarus-run', '-fullscreen=yes', '-cursor-visible=no', '-lua-console=no']
+
+ for nplayer, pad in enumerate(self.controllers, start=1):
+ if nplayer == 1 and 'hotkey' in pad.inputs and 'start' in pad.inputs:
+ args.append(f'-quit-combo={pad.inputs["hotkey"].id}+{pad.inputs["start"].id}')
+ args.append(f'-joypad-num{nplayer}={pad.index}')
+
+ _write_pad_config(self.config_dir, self.controllers, self.config.get_str('joystick'))
+
+ args.append(self.rom)
+
+ return Command(
+ args,
+ env={
+ 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0',
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/sonic3_air.py b/python-src/batocera-launch/batocera_launch/emulators/sonic3_air.py
new file mode 100644
index 00000000000..045da8a84cc
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/sonic3_air.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, SAVES
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_SYSTEM_DIR: Final = Path('/usr/bin/sonic3-air')
+
+
+@cached_dataclass
+class Sonic3AIR(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'sonic3_air',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': 'KEY_F5',
+ 'restore_state': 'KEY_F8',
+ },
+ }
+
+ @cached_property
+ def config_dir(self) -> Path:
+ return CONFIGS / 'Sonic3AIR'
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / 'sonic3-air'
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ config_file = _SYSTEM_DIR / 'config.json'
+ oxygen_file = _SYSTEM_DIR / 'oxygenproject.json'
+ config_dest_file = self.config_dir / 'config.json'
+ oxygen_dest_file = self.config_dir / 'oxygenproject.json'
+ settings_file = self.config_dir / 'settings.json'
+
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ if not config_dest_file.exists():
+ shutil.copy(config_file, config_dest_file)
+ if not oxygen_dest_file.exists():
+ shutil.copy(oxygen_file, oxygen_dest_file)
+
+ # can't use json as the file is not compliant
+ json_text = config_dest_file.read_text()
+ json_text = json_text.replace('"SaveStatesDir": "saves/states"', f'"SaveStatesDir": "{self.saves_dir}"')
+
+ current_resolution = json_text.split('"WindowSize": "')[1].split('"')[0]
+ new_resolution = f'{self.resolution.width} x {self.resolution.height}'
+ json_text = json_text.replace(f'"WindowSize": "{current_resolution}"', f'"WindowSize": "{new_resolution}"')
+ config_dest_file.write_text(json_text)
+
+ if settings_file.exists():
+ settings_data = json.loads(settings_file.read_text())
+ settings_data['Fullscreen'] = 1
+ else:
+ settings_data = {'Fullscreen': 1}
+
+ settings_file.write_text(json.dumps(settings_data, indent=4))
+
+ return Command(
+ [_SYSTEM_DIR / 'sonic3air_linux'],
+ env={
+ 'XDG_DATA_HOME': CONFIGS,
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ 'SDL_AUDIODRIVER': 'alsa',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/sonic_mania.py b/python-src/batocera-launch/batocera_launch/emulators/sonic_mania.py
new file mode 100644
index 00000000000..9de074c5c90
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/sonic_mania.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import (
+ Command,
+ Emulator,
+ HotkeysContext,
+)
+
+_BINARY_SRC: Final = Path('/usr/bin/sonic-mania')
+_ROM_DIR: Final = ROMS / 'sonic-mania'
+
+
+@cached_dataclass
+class SonicMania(Emulator):
+ needs_sdl_game_controller_config = True
+ needs_sdl_controller_db = True
+ sdl_controller_db_path = _ROM_DIR / 'gamecontrollerdb.txt'
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'sonic_mania',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ENTER',
+ 'pause': 'KEY_ENTER',
+ },
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return _ROM_DIR
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ destination_file = _ROM_DIR / 'sonic-mania'
+ if not destination_file.exists():
+ shutil.copy(_BINARY_SRC, destination_file)
+
+ config = CaseSensitiveConfigParser(interpolation=None)
+ config['Game'] = {
+ 'devMenu': 'y',
+ 'faceButtonFlip': 'n',
+ 'enableControllerDebugging': 'n',
+ 'disableFocusPause': 'n',
+ 'region': '-1',
+ 'language': self.config.get_str('smania_language', '0'),
+ }
+ config['Video'] = {
+ 'windowed': 'n',
+ 'border': 'n',
+ 'exclusiveFS': 'y',
+ 'vsync': self.config.get_str('smania_vsync', 'y'),
+ 'tripleBuffering': self.config.get_str('smania_buffering', 'n'),
+ 'winWidth': '848',
+ 'winHeight': '480',
+ 'refreshRate': '60',
+ 'shaderSupport': 'y',
+ 'screenShader': '1',
+ 'maxPixWidth': '0',
+ }
+ config['Audio'] = {
+ 'streamsEnabled': 'y',
+ 'streamVolume': '1.000000',
+ 'sfxVolume': '1.000000',
+ }
+
+ with (_ROM_DIR / 'Settings.ini').open('w') as configfile:
+ config.write(configfile)
+
+ return Command(
+ [destination_file],
+ env={'SDL_JOYSTICK_HIDAPI': '0'},
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/sonicretro.py b/python-src/batocera-launch/batocera_launch/emulators/sonicretro.py
new file mode 100644
index 00000000000..d5e6e25c550
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/sonicretro.py
@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+import hashlib
+from functools import cache
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.configparser import CaseSensitiveRawConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import (
+ Command,
+ Controller,
+ Emulator,
+ HotkeysContext,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_SONIC_BUTTONS: Final = {
+ 'Up': '11',
+ 'Down': '12',
+ 'Left': '13',
+ 'Right': '14',
+ 'A': '0',
+ 'B': '1',
+ 'C': '2',
+ 'X': '3',
+ 'Y': '22',
+ 'Z': '23',
+ 'L': '9',
+ 'R': '10',
+ 'Select': '4',
+ 'Start': '6',
+}
+
+_SONIC_KEYS: Final = {
+ 'Up': '82',
+ 'Down': '81',
+ 'Left': '80',
+ 'Right': '79',
+ 'A': '29',
+ 'B': '27',
+ 'C': '6',
+ 'X': '4',
+ 'Y': '22',
+ 'Z': '7',
+ 'L': '20',
+ 'R': '8',
+ 'Start': '40',
+ 'Select': '43',
+}
+
+_ORIGINS_GAME_CONFIG: Final = {
+ # Sonic 1
+ '5250b0e2effa4d48894106c7d5d1ad32',
+ '5771433883e568715e7ac994bb22f5ed',
+ # Sonic 2
+ 'f958285af4a09d2023b4e4f453691c4f',
+ '9fe2dae0a8a2c7d8ef0bed639b3c749f',
+ # Sonic CD
+ 'e723aab26026e4e6d4522c4356ef5a98',
+}
+
+_MOUSE_ROMS: Final = {
+ '1bd5ad366df1765c98d20b53c092a528', # iOS version of SonicCD
+}
+
+
+@cache
+def _get_resolved_path_md5(path: Path) -> str:
+ return hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
+
+
+def _get_path_md5(path: Path) -> str:
+ return _get_resolved_path_md5(path.resolve())
+
+
+@cached_dataclass
+class SonicRetro(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'sonicretro',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'menu': 'KEY_ENTER',
+ 'pause': 'KEY_ENTER',
+ },
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.rom
+
+ @property
+ def needs_mouse(self) -> bool:
+ if self.rom.name.lower().endswith('son'):
+ return False
+ data_file = self.rom / 'Data.rsdk'
+ return data_file.is_file() and _get_path_md5(data_file) in _MOUSE_ROMS
+
+ async def configure(self) -> Command:
+ emu = 'sonic2013' if self.rom.name.lower().endswith('son') else 'soniccd'
+ ini_file = self.rom / 'settings.ini'
+
+ if ini_file.exists():
+ ini_file.unlink()
+
+ sonic_config = CaseSensitiveRawConfigParser(strict=False)
+
+ sonic_config.add_section('Dev')
+ sonic_config.set('Dev', 'DevMenu', self.config.get_bool('devmenu', return_values=('true', 'false')))
+ sonic_config.set('Dev', 'EngineDebugMode', 'false')
+ if emu == 'sonic2013':
+ sonic_config.set('Dev', 'StartingCategory', '255')
+ sonic_config.set('Dev', 'StartingScene', '255')
+ sonic_config.set('Dev', 'StartingPlayer', '255')
+ sonic_config.set('Dev', 'StartingSaveFile', '255')
+ else:
+ sonic_config.set('Dev', 'StartingCategory', '0')
+ sonic_config.set('Dev', 'StartingScene', '0')
+ sonic_config.set('Dev', 'UseSteamDir', 'false')
+ sonic_config.set('Dev', 'FastForwardSpeed', '8')
+ sonic_config.set('Dev', 'UseHQModes', self.config.get_bool('hqmode', True, return_values=('true', 'false')))
+ sonic_config.set('Dev', 'DataFile', 'Data.rsdk')
+
+ sonic_config.add_section('Game')
+ if emu == 'sonic2013':
+ sonic_config.set(
+ 'Game', 'SkipStartMenu', self.config.get_bool('skipstart', return_values=('true', 'false'))
+ )
+ else:
+ sonic_config.set('Game', 'OriginalControls', self.config.get_str('spindash', '-1'))
+ sonic_config.set('Game', 'DisableTouchControls', 'true')
+
+ game_config_bin = self.rom / 'Data' / 'Game' / 'GameConfig.bin'
+ if game_config_bin.is_file() and _get_path_md5(game_config_bin) in _ORIGINS_GAME_CONFIG:
+ sonic_config.set('Game', 'GameType', '1')
+
+ sonic_config.set('Game', 'Language', self.config.get_str('language', '0'))
+
+ sonic_config.add_section('Window')
+ sonic_config.set('Window', 'FullScreen', 'true')
+ sonic_config.set('Window', 'Borderless', 'true')
+ sonic_config.set('Window', 'VSync', self.config.get_bool('vsync', True, return_values=('true', 'false')))
+ sonic_config.set('Window', 'ScalingMode', self.config.get_str('scalingmode', '2'))
+ sonic_config.set('Window', 'WindowScale', '2')
+ sonic_config.set('Window', 'ScreenWidth', '424')
+ sonic_config.set('Window', 'RefreshRate', '60')
+ sonic_config.set('Window', 'DimLimit', '-1')
+
+ sonic_config.add_section('Audio')
+ sonic_config.set('Audio', 'BGMVolume', '1.000000')
+ sonic_config.set('Audio', 'SFXVolume', '1.000000')
+
+ sonic_config.add_section('Keyboard 1')
+ for name, key in _SONIC_KEYS.items():
+ sonic_config.set('Keyboard 1', name, key)
+
+ sonic_config.add_section('Controller 1')
+ if Controller.find_player_number(self.controllers, 1):
+ for name, button in _SONIC_BUTTONS.items():
+ sonic_config.set('Controller 1', name, button)
+
+ with ini_file.open('w') as configfile:
+ sonic_config.write(configfile, space_around_delimiters=False)
+
+ return Command([emu])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/steam.py b/python-src/batocera-launch/batocera_launch/emulators/steam.py
new file mode 100644
index 00000000000..da11ea7a9b4
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/steam.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class Steam(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'steam',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ args: list[str | Path] = ['batocera-steam']
+ if self.rom.name != 'Steam.steam':
+ args.append(self.rom.read_text().strip())
+
+ # Fix for Xbox Bluetooth controllers not working with Steam (issue #12731)
+ # xpadneo fixes mappings at evdev level, but Steam reads raw HIDAPI data
+ return Command(args, env={'SDL_JOYSTICK_HIDAPI_XBOX': '0'})
diff --git a/python-src/batocera-launch/batocera_launch/emulators/stella.py b/python-src/batocera-launch/batocera_launch/emulators/stella.py
new file mode 100644
index 00000000000..49329c32975
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/stella.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Stella(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'stella',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ return Command(['stella', self.rom])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/taradino.py b/python-src/batocera-launch/batocera_launch/emulators/taradino.py
new file mode 100644
index 00000000000..1d7a6594370
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/taradino.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import CONFIGS, ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Taradino(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'taradino',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ return Command(
+ ['taradino'],
+ env={
+ 'XDG_DATA_HOME': CONFIGS,
+ 'XDG_DATA_DIRS': ROMS / 'rott',
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ 'SDL_VIDEODRIVER': 'x11',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/theforceengine.py b/python-src/batocera-launch/batocera_launch/emulators/theforceengine.py
new file mode 100644
index 00000000000..622d3ab17b7
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/theforceengine.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.configparser import CaseSensitiveConfigParser
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_PATCH_FILE: Final = 'df_patch4.zip'
+
+
+@cached_dataclass
+class TheForceEngine(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'theforceengine',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': ['KEY_LEFTALT', 'KEY_F5'],
+ 'restore_state': ['KEY_LEFTALT', 'KEY_F9'],
+ },
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.config.get_bool('force_widescreen') else 4 / 3
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ mods_dir = self.config_dir / 'Mods'
+ mods_dir.mkdir(parents=True, exist_ok=True)
+
+ mod_name: str | None = None
+ if (mods_dir / _PATCH_FILE).exists():
+ mod_name = _PATCH_FILE
+
+ first_line = self.rom.read_text().splitlines()
+ if first_line and first_line[0].strip():
+ mod_name = first_line[0].strip()
+
+ force_config = CaseSensitiveConfigParser(interpolation=None)
+ settings_file = self.config_dir / 'settings.ini'
+ if settings_file.exists():
+ force_config.read(settings_file)
+
+ if not force_config.has_section('Window'):
+ force_config.add_section('Window')
+ force_config.set('Window', 'width', str(self.resolution.width))
+ force_config.set('Window', 'height', str(self.resolution.height))
+ force_config.set('Window', 'fullscreen', 'true')
+
+ if not force_config.has_section('Graphics'):
+ force_config.add_section('Graphics')
+
+ res_height = self.config.get_int('force_render_res', self.resolution.height)
+ res_width = int(res_height) * 4 / 3
+ res_width_int = int(res_width)
+ force_config.set(
+ 'Graphics',
+ 'gameWidth',
+ str(res_width_int if res_width == res_width_int else res_width),
+ )
+ force_config.set('Graphics', 'gameHeight', str(res_height))
+ force_config.set(
+ 'Graphics',
+ 'widescreen',
+ self.config.get_bool('force_widescreen', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Graphics',
+ 'vsync',
+ self.config.get_bool('force_vsync', True, return_values=('true', 'false')),
+ )
+ force_config.set('Graphics', 'frameRateLimit', self.config.get_str('force_rate', '60'))
+ force_config.set('Graphics', 'renderer', self.config.get_str('force_api', '1'))
+ force_config.set('Graphics', 'colorMode', self.config.get_str('force_colour', '0'))
+ force_config.set(
+ 'Graphics',
+ 'useBilinear',
+ self.config.get_bool('force_bilinear', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Graphics',
+ 'useMipmapping',
+ self.config.get_bool('force_mipmapping', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Graphics',
+ 'reticleEnable',
+ self.config.get_bool('force_crosshair', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Graphics',
+ 'bloomEnabled',
+ self.config.get_bool('force_postfx', return_values=('true', 'false')),
+ )
+
+ if not force_config.has_section('Hud'):
+ force_config.add_section('Hud')
+ force_config.set('Hud', 'hudScale', '"Proportional"')
+ force_config.set('Hud', 'hudPos', '"Edge"')
+ force_config.set('Hud', 'scale', '1.000')
+
+ if not force_config.has_section('Enhancements'):
+ force_config.add_section('Enhancements')
+
+ force_hd = self.config.get_bool('force_hd', return_values=('1', '0'))
+ force_config.set('Enhancements', 'hdTextures', force_hd)
+ force_config.set('Enhancements', 'hdSprites', force_hd)
+ force_config.set('Enhancements', 'hdHud', force_hd)
+
+ if force_hd == '1':
+ force_config.set('Graphics', 'colorMode', '2')
+
+ if not force_config.has_section('Sound'):
+ force_config.add_section('Sound')
+ force_config.set(
+ 'Sound',
+ 'disableSoundInMenus',
+ self.config.get_bool('force_menu_sound', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Sound',
+ 'use16Channels',
+ self.config.get_bool('force_digital_audio', return_values=('true', 'false')),
+ )
+
+ if not force_config.has_section('System'):
+ force_config.add_section('System')
+ if not force_config.has_section('A11y'):
+ force_config.add_section('A11y')
+
+ if not force_config.has_section('Game'):
+ force_config.add_section('Game')
+ force_config.set('Game', 'game', 'Dark Forces')
+
+ if not force_config.has_section('Dark_Forces'):
+ force_config.add_section('Dark_Forces')
+
+ dark_forces_path = self.roms_dir / 'Star Wars - Dark Forces'
+ force_config.set('Dark_Forces', 'sourcePath', f'"{dark_forces_path}/"')
+ force_config.set(
+ 'Dark_Forces',
+ 'disableFightMusic',
+ self.config.get_bool('force_fight_music', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Dark_Forces',
+ 'enableAutoaim',
+ self.config.get_bool('force_auto_aim', True, return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Dark_Forces',
+ 'showSecretFoundMsg',
+ self.config.get_bool('force_secret_msg', True, return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Dark_Forces',
+ 'autorun',
+ self.config.get_bool('force_auto_run', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Dark_Forces',
+ 'bobaFettFacePlayer',
+ self.config.get_bool('force_boba', return_values=('true', 'false')),
+ )
+ force_config.set(
+ 'Dark_Forces',
+ 'smoothVUEs',
+ self.config.get_bool('force_smooth_vues', return_values=('true', 'false')),
+ )
+
+ if not force_config.has_section('Outlaws'):
+ force_config.add_section('Outlaws')
+ force_config.set('Outlaws', 'sourcePath', '""')
+
+ if not force_config.has_section('CVar'):
+ force_config.add_section('CVar')
+
+ with settings_file.open('w') as configfile:
+ force_config.write(configfile)
+
+ args: list[str | Path] = ['theforceengine']
+
+ skip_cutscenes = self.config.get_str('force_skip_cutscenes')
+ if skip_cutscenes == 'initial':
+ args.append('-c0')
+ elif skip_cutscenes == 'skip':
+ args.append('-c')
+
+ if mod_name is not None:
+ args.append(f'-u{mod_name}')
+
+ args.append('-gDARK')
+
+ return Command(args, env={'TFE_DATA_HOME': self.config_dir})
diff --git a/python-src/batocera-launch/batocera_launch/emulators/thextech.py b/python-src/batocera-launch/batocera_launch/emulators/thextech.py
new file mode 100644
index 00000000000..cb7e835662c
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/thextech.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class TheXTech(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'thextech',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4'], 'menu': 'KEY_ENTER', 'pause': 'KEY_ENTER'},
+ }
+
+ async def configure(self) -> Command:
+ args: list[str | Path] = ['/usr/bin/thextech', '-u', self.saves_dir]
+
+ if rendering_mode := self.config.get_str('rendering_mode'):
+ args.extend(['-r', rendering_mode])
+
+ args.append(self.config.get_bool('frameskip', True, return_values=('--frameskip', '--no-frameskip')))
+ args.extend(['-c', f'{self.rom}/'])
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/tic80.py b/python-src/batocera-launch/batocera_launch/emulators/tic80.py
new file mode 100644
index 00000000000..670a6125200
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/tic80.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class Tic80(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'tic80',
+ 'keys': {
+ 'exit': ['KEY_LEFTCTRL', 'KEY_Q'],
+ 'menu': 'KEY_ENTER',
+ 'reset': ['KEY_LEFTCTRL', 'KEY_R'],
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ args: list[str | Path] = ['tic80', '--fullscreen', '--skip']
+
+ if self.rom.stem.lower() in {'surf', 'console'}:
+ args.append('--cmd=surf')
+ else:
+ args.append(self.rom)
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/trx.py b/python-src/batocera-launch/batocera_launch/emulators/trx.py
new file mode 100644
index 00000000000..f21489a5b82
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/trx.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+from pathlib import Path
+from typing import Any, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_logger = logging.getLogger(__name__)
+
+_VALID_MODS: Final = {
+ 'tr1',
+ 'tr1-ub',
+ 'tr1-demo-pc',
+ 'tr1-level',
+ 'tr2',
+ 'tr2-gm',
+ 'tr2-level',
+ 'tr3',
+ 'tr3-la',
+ 'tr3-level',
+}
+
+
+@cached_dataclass
+class TRX(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'trx',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4'], 'save_state': 'KEY_F5', 'restore_state': 'KEY_F6'},
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.resolution.width / self.resolution.height > ((16.0 / 9.0) - 0.1) else 4 / 3
+
+ async def configure(self) -> Command:
+ rom_dir = self.roms_dir
+ source_path = Path('/usr/bin/trx')
+
+ # Copy shared package configurations/shaders etc
+ for item in source_path.iterdir():
+ dest = rom_dir / item.name
+ try:
+ if item.is_dir():
+ if not dest.exists():
+ shutil.copytree(item, dest, dirs_exist_ok=True)
+ else:
+ for sub_item in item.rglob('*'):
+ sub_dest = dest / sub_item.relative_to(item)
+ if sub_item.is_dir():
+ sub_dest.mkdir(parents=True, exist_ok=True)
+ else:
+ shutil.copy2(sub_item, sub_dest)
+ else:
+ shutil.copy2(item, dest)
+ except PermissionError as e:
+ _logger.debug('Permission error while copying %s -> %s: %s', item, dest, e)
+ except Exception as e:
+ _logger.debug('Error copying %s -> %s: %s', item, dest, e)
+
+ trx_bin = rom_dir / 'TRX'
+ if trx_bin.exists():
+ trx_bin.chmod(0o755)
+
+ # Detect mod from the launcher file's parent folder
+ mod = self.rom.parent.name if self.rom.parent.name in _VALID_MODS else 'tr1'
+
+ # Each engine (TR1/TR2/TR3) keeps its own settings file, named TRX.json5
+ engine_version = mod[2] if len(mod) > 2 and mod[2].isdigit() else '1'
+ config_path = rom_dir / 'cfg' / f'TR{engine_version}X.json5'
+
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_data: dict[str, Any] = {}
+
+ if config_path.exists():
+ try:
+ config_data = json.loads(config_path.read_text(encoding='utf-8'))
+ except json.JSONDecodeError:
+ _logger.debug('Invalid JSON format in %s, overwriting with default settings.', config_path)
+
+ config_data.update({'is_fullscreen': True, 'width': self.resolution.width, 'height': self.resolution.height})
+ config_path.write_text(json.dumps(config_data, indent=2), encoding='utf-8')
+
+ return Command([trx_bin, '--mod', mod], {'SDL_JOYSTICK_HIDAPI': '0'})
diff --git a/python-src/batocera-launch/batocera_launch/emulators/tsugaru.py b/python-src/batocera-launch/batocera_launch/emulators/tsugaru.py
new file mode 100644
index 00000000000..8918d7aa902
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/tsugaru.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_CD_SUFFIXES: Final = {'.iso', '.cue', '.bin'}
+
+
+@cached_dataclass
+class Tsugaru(Emulator):
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'tsugaru',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4'], 'menu': 'KEY_F10', 'pause': 'KEY_F10'},
+ }
+
+ async def configure(self) -> Command:
+ args: list[str | Path] = [
+ '/usr/bin/Tsugaru_CUI',
+ BIOS / 'fmtowns',
+ '-FULLSCREEN',
+ '-NOHIGHRESPCM',
+ '-NOWAITBOOT',
+ '-AUTOSCALE',
+ '-MAINTAINASPECT',
+ '-HIGHRES',
+ '-GAMEPORT0',
+ 'KEY',
+ '-KEYBOARD',
+ 'DIRECT',
+ '-PAUSEKEY',
+ 'F10',
+ ]
+
+ if (cdrom_speed := self.config.get_str('cdrom_speed', 'auto')) != 'auto':
+ args.extend(['-CDSPEED', cdrom_speed])
+
+ if self.config.get_bool('386dx'):
+ args.append('-PRETEND386DX')
+
+ if self.rom.suffix.lower() in _CD_SUFFIXES:
+ args.extend(['-CD', self.rom])
+ else:
+ args.extend(['-FD0', self.rom])
+
+ return Command(args)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/tyrian.py b/python-src/batocera-launch/batocera_launch/emulators/tyrian.py
new file mode 100644
index 00000000000..e398c3f6113
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/tyrian.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import ROMS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+_DATA_DIR: Final = ROMS / 'tyrian' / 'data'
+
+
+@cached_dataclass
+class Tyrian(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'tyrian',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @cached_property
+ def data_dir(self) -> Path:
+ return self.roms_dir / 'data'
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.data_dir
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9
+
+ async def configure(self) -> Command:
+ if not self.data_dir.is_dir():
+ _logger.error('ERROR: Game assets not installed. You can get them from the Batocera Content Downloader.')
+
+ return Command(['opentyrian'])
diff --git a/python-src/batocera-launch/batocera_launch/emulators/uqm.py b/python-src/batocera-launch/batocera_launch/emulators/uqm.py
new file mode 100644
index 00000000000..e2a05ccd8dc
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/uqm.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+
+@cached_dataclass
+class Uqm(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'uqm',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ async def configure(self) -> Command:
+ (self.saves_dir / 'teams').mkdir(parents=True, exist_ok=True)
+ (self.saves_dir / 'save').mkdir(parents=True, exist_ok=True)
+ (self.roms_dir / 'version').touch(exist_ok=True)
+
+ return Command(
+ [
+ 'urquan',
+ f'--contentdir={self.roms_dir}',
+ f'--configdir={self.saves_dir}',
+ ]
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/vkquake.py b/python-src/batocera-launch/batocera_launch/emulators/vkquake.py
new file mode 100644
index 00000000000..68bc53f57b8
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/vkquake.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class VKQuake(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'vkquake',
+ 'keys': {'exit': 'KEY_F10', 'save_state': 'KEY_F6', 'restore_state': 'KEY_F9'},
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.resolution.width / self.resolution.height > ((16.0 / 9.0) - 0.1) else 4 / 3
+
+ async def configure(self) -> Command:
+ args: list[str | Path] = ['/usr/bin/vkquake', '-basedir', self.roms_dir]
+ rom_name = self.rom.name.lower()
+
+ if 'scourge' in rom_name:
+ args.append('-hipnotic')
+ if 'dissolution' in rom_name:
+ args.append('-rogue')
+
+ return Command(args, env={'SDL_JOYSTICK_HIDAPI': '0'})
diff --git a/python-src/batocera-launch/batocera_launch/emulators/vkquake2.py b/python-src/batocera-launch/batocera_launch/emulators/vkquake2.py
new file mode 100644
index 00000000000..fee4f400d69
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/vkquake2.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import BatoceraException, Command, Emulator, HotkeysContext
+
+_SOURCE_DIR: Final = Path('/usr/bin/vkquake2')
+
+
+@cached_dataclass
+class VKQuake2(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'vkquake2',
+ 'keys': {'exit': 'KEY_F10', 'save_state': 'KEY_F6', 'restore_state': 'KEY_F7'},
+ }
+
+ @property
+ def execution_path(self) -> Path | None:
+ return self.roms_dir
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.resolution.width / self.resolution.height > ((16.0 / 9.0) - 0.1) else 4 / 3
+
+ async def configure(self) -> Command:
+ if not _SOURCE_DIR.exists():
+ raise BatoceraException(f'Source directory {_SOURCE_DIR} does not exist.')
+
+ shutil.copytree(_SOURCE_DIR, self.roms_dir, dirs_exist_ok=True, copy_function=shutil.copy2)
+
+ args: list[str | Path] = [self.roms_dir / 'quake2']
+ rom_name = self.rom.name.lower()
+
+ if 'zero' in rom_name:
+ args.extend(['+set', 'game', 'rogue'])
+ if 'reckoning' in rom_name:
+ args.extend(['+set', 'game', 'xatrix'])
+ if 'zaero' in rom_name:
+ args.extend(['+set', 'game', 'zaero'])
+ if 'destruction' in rom_name:
+ args.extend(['+set', 'game', 'smd'])
+
+ return Command(args, env={'SDL_JOYSTICK_HIDAPI': '0'})
diff --git a/python-src/batocera-launch/batocera_launch/emulators/x16emu.py b/python-src/batocera-launch/batocera_launch/emulators/x16emu.py
new file mode 100644
index 00000000000..555cd21c876
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/x16emu.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS, CONFIGS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+@cached_dataclass
+class X16emu(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'x16emu',
+ 'keys': {'exit': ['KEY_LEFTALT', 'KEY_F4']},
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.config.get_str('x16emu_ratio') == '16:9' else 4 / 3
+
+ async def configure(self) -> Command:
+ rom_dir = self.rom.parent
+ args: list[str | Path] = [
+ 'x16emu',
+ '-rom',
+ BIOS / 'commanderx16' / 'rom.bin',
+ '-fsroot',
+ rom_dir,
+ '-ram',
+ '2048',
+ '-rtc',
+ ]
+
+ if self.rom.suffix == '.img':
+ args.extend(['-sdcard', self.rom])
+ elif self.rom.suffix == '.bas':
+ args.extend(['-bas', self.rom])
+ else:
+ args.extend(['-prg', self.rom, '-run'])
+
+ autorun_cmd = rom_dir / 'autorun.cmd'
+ if autorun_cmd.exists():
+ args.extend(['-bas', autorun_cmd])
+
+ args.extend(['-scale', self.config.get_str('x16emu_scale', '2')])
+
+ if quality := self.config.get_str('x16emu_quality'):
+ args.extend(['-quality', quality])
+
+ if self.config.get_str('x16emu_ratio') == '16:9':
+ args.append('-widescreen')
+
+ for nplayer, _ in enumerate(self.controllers[:4], start=1):
+ args.append(f'-joy{nplayer}')
+
+ return Command(
+ args,
+ env={
+ 'XDG_DATA_HOME': CONFIGS,
+ 'SDL_JOYSTICK_HIDAPI': '0',
+ },
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/__init__.py b/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/__init__.py
new file mode 100644
index 00000000000..47a1dff61d5
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+from .emulator import Xash3dFwgs as Xash3dFwgs
diff --git a/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/emulator.py b/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/emulator.py
new file mode 100644
index 00000000000..1413e8cc85e
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/emulator.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import re
+import shutil
+from importlib import resources
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import SAVES
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_HLSDK_LIBS_DIR: Final = Path('/usr/lib/xash3d/hlsdk')
+_DEFAULT_SERVER_LIB: Final = 'hl'
+_GAMEDLL_RE: Final = re.compile(r'gamedll\w*\s+"(?:dlls[/\\])?([^.]*)')
+
+
+def _client_lib_path(server_lib: str, arch_suffix: str) -> Path:
+ return _HLSDK_LIBS_DIR / server_lib / 'cl_dlls' / f'client{arch_suffix}.so'
+
+
+def _server_lib_path(server_lib: str, arch_suffix: str) -> Path:
+ return _HLSDK_LIBS_DIR / server_lib / 'dlls' / f'{server_lib}{arch_suffix}.so'
+
+
+def _find_server_lib(server_lib: str | None, arch_suffix: str) -> Path:
+ if server_lib:
+ path = _server_lib_path(server_lib, arch_suffix)
+ if path.exists():
+ return path
+
+ return _server_lib_path(_DEFAULT_SERVER_LIB, arch_suffix)
+
+
+def _find_client_lib(server_lib: str | None, arch_suffix: str) -> Path:
+ if server_lib:
+ path = _client_lib_path(server_lib, arch_suffix)
+ if path.exists():
+ return path
+
+ return _client_lib_path(_DEFAULT_SERVER_LIB, arch_suffix)
+
+
+def _get_arch_suffix() -> str:
+ path_prefix = _HLSDK_LIBS_DIR / 'hl' / 'dlls'
+ return next(path_prefix.glob('hl*.so')).stem[2:]
+
+
+@cached_dataclass
+class Xash3dFwgs(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'xash3dFwgs',
+ 'keys': {
+ 'exit': 'KEY_F10',
+ 'menu': 'KEY_ESC',
+ 'pause': 'KEY_ESC',
+ 'save_state': 'KEY_F6',
+ 'restore_state': 'KEY_F7',
+ },
+ }
+
+ @cached_property
+ def rom_data_dir(self) -> Path:
+ return self.roms_dir / self.rom.stem
+
+ @cached_property
+ def saves_dir(self) -> Path:
+ return SAVES / 'xash3d_fwgs'
+
+ async def configure(self) -> Command:
+ arch_suffix = _get_arch_suffix()
+ server_lib = self._get_server_lib_basename_from_liblist_gam()
+
+ # By default, xash3d will use `dlls/hl.so` in the valve directory (via the `liblist.gam` config file).
+ # However, that `so` is incompatible with xash3d (it's the x86-glibc version from Valve).
+ # We instead point to the hlsdk-xash3d `so`.
+ args: list[str | Path] = [
+ '/usr/bin/xash3d',
+ '-fullscreen',
+ '-dev',
+ '-clientlib',
+ _find_client_lib(server_lib, arch_suffix),
+ '-dll',
+ _find_server_lib(server_lib, arch_suffix),
+ '-game',
+ self.rom.stem,
+ '+cl_showfps',
+ '1' if self.config.show_fps else '0',
+ ]
+
+ self._maybe_init_config()
+ self._maybe_init_save_dir()
+
+ return Command(
+ args,
+ env={
+ 'XASH3D_BASEDIR': self.rom_data_dir,
+ 'XASH3D_EXTRAS_PAK1': '/usr/share/xash3d/valve/extras.pk3',
+ 'LD_LIBRARY_PATH': '/usr/lib/xash3d',
+ },
+ )
+
+ def _get_server_lib_basename_from_liblist_gam(self) -> str | None:
+ path = self.rom_data_dir / 'liblist.gam'
+ if not path.exists():
+ return None
+
+ for line in path.read_text().splitlines():
+ if m := _GAMEDLL_RE.match(line):
+ return m.group(1)
+
+ return None
+
+ def _maybe_init_config(self) -> None:
+ user_config = self.rom_data_dir / 'userconfig.cfg'
+ if not user_config.exists():
+ user_config.write_text('exec gamepad.cfg\nexec custom.cfg\n')
+
+ gamepad_config = self.rom_data_dir / 'gamepad.cfg'
+ if not gamepad_config.exists():
+ with resources.as_file(resources.files().joinpath('gamepad.cfg')) as gamepad_cfg:
+ shutil.copy(gamepad_cfg, gamepad_config)
+
+ config_dir = self.config_dir / self.rom.stem
+ custom_config = config_dir / 'custom.cfg'
+ custom_rom_config = self.rom_data_dir / 'custom.cfg'
+ if not custom_config.exists():
+ config_dir.mkdir(parents=True, exist_ok=True)
+ custom_config.write_text('\n')
+ if not custom_rom_config.exists():
+ custom_rom_config.symlink_to(custom_config)
+
+ def _maybe_init_save_dir(self) -> None:
+ rom_save_dir = self.rom_data_dir / 'save'
+ if not rom_save_dir.exists():
+ save_dir = self.saves_dir / self.rom.stem
+ save_dir.mkdir(parents=True, exist_ok=True)
+ rom_save_dir.symlink_to(save_dir)
diff --git a/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/gamepad.cfg b/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/gamepad.cfg
new file mode 100644
index 00000000000..aeab8892085
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/xash3d_fwgs/gamepad.cfg
@@ -0,0 +1,61 @@
+// Default gamepad bindings
+
+// == Bindings ==
+// Same as this illustration except the SELECT + combinations that are different and follow Batocera conventions:
+// https://raw.githubusercontent.com/krishenriksen/Half-Life-rg351p/da77c22b60c29589240b86608495574672105206/Half-Life/controls.png
+//
+// Left stick: Move/Strafe (Walk instead of run when pressed)
+// Right stick: Look (Crouch when pressed)
+// D-Pad Up: Spray
+// D-Pad Down: Quick swap weapon
+// D-Pad Left: Prev Weapon
+// D-Pad Right: Next Weapon
+// A: Use
+// B: Jump
+// X: Flashlight
+// Y: Reload
+// L1: Duck
+// L2: Walk (instead of Run)
+// R1: Fire
+// R2: Alt-fire
+// START: Pause
+// SELECT + B: Menu
+// SELECT + X: Quick Save
+// SELECT + Y: Quick Load
+// SELECT + L1: Screenshot
+// SELECT + START: Quit
+
+joy_side_deadzone "4096"
+joy_forward_deadzone "4096"
+joy_pitch_deadzone "4096"
+joy_yaw_deadzone "4096"
+
+bind DPAD_UP "impulse 201"
+bind DPAD_DOWN "lastinv"
+bind DPAD_LEFT "invprev"
+bind DPAD_RIGHT "invnext"
+
+// NOTE: A, B, X, Y refer to physical positions on an XBox 360 controller.
+// A<->B and X<->Y are reversed on a Nintendo controller.
+bind A_BUTTON "+jump"
+bind B_BUTTON "+use"
+bind X_BUTTON "+reload"
+bind Y_BUTTON "impulse 100"
+
+bind L1_BUTTON "+duck"
+bind LTRIGGER "+speed"
+
+bind R1_BUTTON "+attack"
+bind RTRIGGER "+attack2"
+
+bind START "pause"
+bind STICK1 "+speed"
+bind STICK2 "+duck"
+
+// While SELECT is pressed:
+alias "+SelectHold" "bind A_BUTTON cancelselect; bind Y_BUTTON load quick; bind X_BUTTON save quick; bind L1_BUTTON screenshot; bind START quit"
+
+// When SELECT is released, restore the original bindings (duplicated above).
+alias "-SelectHold" "bind A_BUTTON +jump; bind Y_BUTTON impulse 100; bind X_BUTTON +reload; bind L1_BUTTON +duck; bind START pause"
+
+bind BACK "+SelectHold"
diff --git a/python-src/batocera-launch/batocera_launch/emulators/xroar.py b/python-src/batocera-launch/batocera_launch/emulators/xroar.py
new file mode 100644
index 00000000000..65426b44147
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/xroar.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Final
+
+from batocera_common.asyncio import run
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_common.paths import BIOS
+from batocera_launch import Command, Emulator, HotkeysContext
+
+_logger = logging.getLogger(__name__)
+
+_ARCHIVE_SUFFIXES: Final = {'.zip', '.7z'}
+_DISK_SUFFIXES: Final = {'.rom', '.dsk', '.cas', '.ccc', '.wav'}
+_DEFAULT_MACHINES: Final = {'mc10': 'mc10', 'dragon64': 'dragon64'}
+
+
+@cached_dataclass
+class Xroar(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'xroar',
+ 'keys': {'exit': ['KEY_LEFTCTRL', 'KEY_Q']},
+ }
+
+ @property
+ def needs_mouse(self) -> bool:
+ return True
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 4 / 3
+
+ async def configure(self) -> Command:
+ self.config_dir.mkdir(parents=True, exist_ok=True)
+ conf_file = self.config_dir / 'xroar.conf'
+
+ default_machine = _DEFAULT_MACHINES.get(self.system, 'coco2bus')
+ lines = [
+ f'rompath {BIOS / "xroar"}',
+ f'default-machine {self.config.get_str("xroar_machine", default_machine)}',
+ 'ao-volume 100',
+ ]
+
+ if self.config.get_bool('xroar_cartauto'):
+ lines.append('cart-autorun')
+ if self.config.get_bool('xroar_vsync'):
+ lines.append('vo-vsync')
+ if ram := self.config.get_str('xroar_ram'):
+ lines.append(f'ram {ram}')
+ if tv_type := self.config.get_str('xroar_tv_type'):
+ lines.append(f'tv-type {tv_type}')
+ if tv_input := self.config.get_str('xroar_tv_input'):
+ lines.append(f'tv-input {tv_input}')
+ if self.config.get_bool('xroar_kbd_translate'):
+ lines.append('kbd-translate')
+
+ lines.append('fs')
+ conf_file.write_text('\n'.join(lines) + '\n')
+
+ rom_path: str | Path = self.rom
+ if self.rom.suffix.lower() in _ARCHIVE_SUFFIXES:
+ try:
+ proc = await run('batocera-xtract', 'l', self.rom, text=True)
+ if proc.returncode == 10:
+ for line in proc.stdout.splitlines():
+ if Path(line).suffix.lower() in _DISK_SUFFIXES:
+ await run('batocera-xtract', 'pyx', self.rom, '/tmp', line, check=True)
+ rom_path = Path('/tmp') / line
+ break
+
+ if rom_path == self.rom:
+ raise RuntimeError("Can't find a matching file in archive")
+ except Exception:
+ _logger.exception('7z error')
+
+ return Command(
+ ['xroar', '-c', conf_file, rom_path],
+ env={'SDL_JOYSTICK_HIDAPI': '0'},
+ )
diff --git a/python-src/batocera-launch/batocera_launch/emulators/yquake2.py b/python-src/batocera-launch/batocera_launch/emulators/yquake2.py
new file mode 100644
index 00000000000..c813907935e
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/emulators/yquake2.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+import platform
+from pathlib import Path
+from typing import Final
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+from batocera_launch import Command, Controller, Emulator, HotkeysContext
+
+_DEFAULT_OPTIONS: Final = {
+ # Special keys for hotkey shortcuts
+ 'bind PRINT': 'screenshot jpg 90',
+ 'bind MENU': 'menu_joy',
+ # Default gamepad controls
+ 'bind SHOULDR_LEFT': '+movedown',
+ 'bind TRIG_LEFT': '+moveup',
+ 'bind SHOULDR_RIGHT': '+joyaltselector',
+ 'bind TRIG_RIGHT': '+attack',
+ 'bind BTN_SOUTH': '+moveup',
+ 'bind BTN_EAST': '+movedown',
+ 'bind BTN_WEST': 'weapnext',
+ 'bind BTN_NORTH': 'weapprev',
+ 'bind BTN_BACK': 'cmd help',
+ 'bind BTN_GUIDE': '',
+ 'bind STICK_LEFT': '',
+ 'bind STICK_RIGHT': 'centerview',
+ 'bind DP_UP': (
+ 'cycleweap weapon_plasmabeam weapon_boomer weapon_chaingun weapon_etf_rifle weapon_machinegun weapon_blaster'
+ ),
+ 'bind DP_DOWN': 'cycleweap weapon_supershotgun weapon_shotgun weapon_chainfist',
+ 'bind DP_LEFT': (
+ 'cycleweap weapon_phalanx weapon_rocketlauncher weapon_proxlauncher weapon_grenadelauncher ammo_grenades'
+ ),
+ 'bind DP_RIGHT': (
+ 'cycleweap weapon_bfg weapon_disintegrator weapon_railgun weapon_hyperblaster ammo_tesla ammo_trap'
+ ),
+ 'bind BTN_WEST_ALT': 'invuse',
+ 'bind BTN_NORTH_ALT': 'invdrop',
+ 'bind BTN_BACK_ALT': 'inven',
+ 'bind DP_UP_ALT': 'invprev',
+ 'bind DP_DOWN_ALT': 'invnext',
+ 'bind DP_LEFT_ALT': 'invprev',
+ 'bind DP_RIGHT_ALT': 'invnext',
+ # Gameplay options, check YQ2 documentation
+ 'set aimfix': '1',
+ 'set cl_run': '1',
+ 'set g_machinegun_norecoil': '1',
+ 'set g_quick_weap': '1',
+ 'set g_swap_speed': '2',
+ # Audio & Video
+ 'set ogg_ignoretrack0': '1',
+ 'set gl_znear': '3.2',
+ 'set vid_fullscreen': '1',
+ 'set r_mode': '-2',
+}
+
+
+def _cpu_max_mhz() -> int:
+ try:
+ for path in Path('/sys/devices/system/cpu').glob('cpu*/cpufreq/cpuinfo_max_freq'):
+ return int(path.read_text().strip()) // 1000
+ except OSError, ValueError:
+ pass
+ return 0
+
+
+def _is_pc() -> bool:
+ return platform.machine().lower().startswith('x86')
+
+
+@cached_dataclass
+class YQuake2(Emulator):
+ needs_sdl_game_controller_config = True
+
+ @cached_property
+ def hotkeygen_context(self) -> HotkeysContext:
+ return {
+ 'name': 'yquake2',
+ 'keys': {
+ 'exit': ['KEY_LEFTALT', 'KEY_F4'],
+ 'save_state': 'KEY_F6',
+ 'restore_state': 'KEY_F9',
+ },
+ }
+
+ @cached_property
+ def in_game_ratio(self) -> float:
+ return 16 / 9 if self.resolution.width / self.resolution.height > ((16.0 / 9.0) - 0.1) else 4 / 3
+
+ def _create_default_config(self) -> None:
+ config_file = self.config_dir / 'baseq2' / 'yq2.cfg'
+ config_file.parent.mkdir(parents=True, exist_ok=True)
+
+ # Define the default options to add
+ options = dict(_DEFAULT_OPTIONS)
+
+ # Disable OpenAL on slow CPUs
+ if _cpu_max_mhz() < 2000:
+ options['set s_openal'] = '0'
+
+ if not _is_pc():
+ options['set gl1_discardfb'] = '1'
+ options['set gl1_lightmapcopies'] = '1'
+ options['set gl1_pointparameters'] = '0'
+
+ config_file.write_text(''.join(f'{key} "{value}"\n' for key, value in options.items()))
+
+ async def configure(self) -> Command:
+ config_file = self.config_dir / 'baseq2' / 'yq2.cfg'
+ if not config_file.exists():
+ self._create_default_config()
+
+ swap_buttons = '1' if self.es_settings.get_bool('InvertButtons') else '0'
+ args: list[str | Path] = [
+ '/usr/bin/yquake2/quake2',
+ '-cfgdir',
+ 'configs/yquake2',
+ '+set',
+ 'joy_confirm',
+ swap_buttons,
+ ]
+
+ if pad := Controller.find_player_number(self.controllers, 1):
+ args.extend(['+set', 'in_initjoy', str(pad.index + 1)])
+
+ # Mission Packs
+ rom_name = self.rom.name.lower()
+ if 'reckoning' in rom_name:
+ args.extend(['+set', 'game', 'xatrix'])
+ elif 'zero' in rom_name:
+ args.extend(['+set', 'game', 'rogue'])
+ elif 'zaero' in rom_name:
+ args.extend(['+set', 'game', 'zaero'])
+
+ return Command(args, env={'SDL_JOYSTICK_HIDAPI': '0'})
diff --git a/python-src/batocera-launch/batocera_launch/exceptions.py b/python-src/batocera-launch/batocera_launch/exceptions.py
new file mode 100644
index 00000000000..8798cadf676
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/exceptions.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from typing import ClassVar
+
+
+class BaseBatoceraException(Exception):
+ EXIT_CODE: ClassVar = 1
+
+ @property
+ def exit_code(self) -> int:
+ return self.EXIT_CODE
+
+
+class BatoceraException(BaseBatoceraException):
+ @property
+ def exit_code(self) -> int:
+ if self.args and isinstance(self.args[0], str):
+ return 250
+
+ return self.EXIT_CODE
+
+
+class UnexpectedEmulatorExit(BaseBatoceraException):
+ EXIT_CODE = 200
+
+
+class BadCommandLineArguments(BaseBatoceraException):
+ EXIT_CODE = 201
+
+
+class InvalidConfiguration(BaseBatoceraException):
+ EXIT_CODE = 202
+
+
+class UnknownEmulator(BaseBatoceraException):
+ EXIT_CODE = 203
+
+
+class MissingEmulator(BaseBatoceraException):
+ EXIT_CODE = 204
+
+
+class MissingCore(BaseBatoceraException):
+ EXIT_CODE = 205
diff --git a/python-src/batocera-launch/batocera_launch/fs/__init__.py b/python-src/batocera-launch/batocera_launch/fs/__init__.py
new file mode 100644
index 00000000000..cd503d6b3c4
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/fs/__init__.py
@@ -0,0 +1,4 @@
+from __future__ import annotations
+
+from .overlayfs import mount_overlayfs as mount_overlayfs
+from .squashfs import mount_squashfs as mount_squashfs
diff --git a/python-src/batocera-launch/batocera_launch/fs/mounts.py b/python-src/batocera-launch/batocera_launch/fs/mounts.py
new file mode 100644
index 00000000000..920cd5947e3
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/fs/mounts.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from subprocess import CalledProcessError
+from typing import TYPE_CHECKING
+
+from batocera_common.asyncio import run
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+
+# a game that was killed rather than closed leaves processes holding the mount while they
+# die, so a busy mount is worth waiting on rather than giving up on straight away
+async def unmount(mount_point: Path, /, *, attempts: int = 10, delay: float = 0.5) -> bool:
+ for attempt in range(attempts):
+ try:
+ await run('umount', mount_point, text=True, check=True)
+
+ return True
+ except CalledProcessError as e:
+ if e.returncode != 0 and 'busy' not in e.stderr:
+ # No need to retry if the device isn't busy. Log it and return False.
+ _logger.exception("Failed to unmount '%s' because %s", mount_point, e.stderr.strip())
+ return False
+
+ if not attempt:
+ # Log on the first attempt only, to avoid spamming the logs
+ _logger.debug("'%s' is busy, waiting for it to be released", mount_point)
+
+ if attempt < attempts - 1:
+ await asyncio.sleep(delay)
+
+ _logger.warning("'%s' is still busy, detaching it lazily", mount_point)
+
+ try:
+ # detach it anyway, the kernel drops it once the last process lets go: leaving it
+ # mounted would keep the rom busy until the next reboot
+ await run('umount', '-l', mount_point, text=True, check=True)
+ except CalledProcessError as e:
+ _logger.exception("Failed to lazily detach '%s' because %s", mount_point, e.stderr.strip())
+ return False
+
+ return True
diff --git a/python-src/batocera-launch/batocera_launch/fs/overlayfs.py b/python-src/batocera-launch/batocera_launch/fs/overlayfs.py
new file mode 100644
index 00000000000..fb95f0c6efa
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/fs/overlayfs.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+import logging
+import shutil
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING
+
+from batocera_common.asyncio import run
+from batocera_common.paths import ROM_OVERLAY_DIR
+
+from ..exceptions import BatoceraException
+from .mounts import unmount
+
+if TYPE_CHECKING:
+ from _typeshed import StrPath
+ from collections.abc import AsyncGenerator
+ from pathlib import Path
+
+
+_logger = logging.getLogger(__name__)
+
+
+async def _unmount_and_remove(mount_point: Path, /) -> None:
+ if mount_point.is_mount() and not await unmount(mount_point):
+ _logger.error("failed unmounting '%s'", mount_point)
+
+ # Skip the follow-on removal if the umount failed because it might still
+ # be connected to the ROM's save area (system crashing or bad state?).
+ return
+
+ shutil.rmtree(mount_point, ignore_errors=True)
+
+
+def _escape_value(value: StrPath, /, *, include_colons: bool = False) -> str:
+ """Escape a value for use in an overlayfs mount option string."""
+
+ result = str(value).replace('\\', '\\\\').replace(',', r'\,')
+
+ if include_colons:
+ return result.replace(':', r'\:')
+
+ return result
+
+
+async def _mount(read_only_dir: Path, writable_upper_dir: Path, writable_work_dir: Path, mount_point: Path, /) -> bool:
+ components = ','.join(
+ (
+ f'lowerdir={_escape_value(read_only_dir, include_colons=True)}',
+ f'upperdir={_escape_value(writable_upper_dir)}',
+ f'workdir={_escape_value(writable_work_dir)}',
+ )
+ )
+
+ try:
+ await run('mount', '-t', 'overlay', 'overlay', '-o', components, mount_point, text=True, check=True)
+ except Exception:
+ _logger.exception("failed mounting '%s' with components '%s'", mount_point, components)
+
+ return False
+
+ _logger.debug("mounted '%s' with components '%s'", mount_point, components)
+
+ return True
+
+
+@asynccontextmanager
+async def mount_overlayfs(read_only_dir: Path, writable_dir: Path, /) -> AsyncGenerator[Path]:
+ """
+ The Linux kernel's overlay file system (overlayfs) creates a virtual file
+ system mount point based on a "stack" of two or more of underlying directory
+ trees where the lower directories get occluded (or overridden by)
+ directories higher up the stack.
+
+ Three things to note:
+
+ - Changes are only written to the top-most directory or the 'upper'
+ directory, in overlayfs jargon, while the 'lower' directories are
+ effectively read-only.
+
+ - Overlayfs operates at the logical file level as opposed to operating at
+ the block-level, so it's perfectly fine (and normal) to mix file systems,
+ such as a squashfs mount point being used as the read-only lower layer and
+ some writable directory (coming from ext4 or even tmpfs) as the writable
+ layer.
+
+ - Overlayfs requires the writable directory be split into 'upper' and 'work'
+ subdirectories. The 'work' directory is where in-flight changes accumulate
+ before being atomically moved into the public 'upper' area. You'll see
+ these "upper" and "work" sub-directories being created off the passed in
+ "writable_dir" in the code below. This is a requirement of overlayfs and
+ essentially internal workings. They must both exist on the same file
+ system for the atomic moves to work properly.
+
+ The main use-case in batocera will be supporting writes on top of a
+ read-only underlying directory tree of files, specifically for emulators
+ that take in a tree of files like DOS, Wine, Amiga, and probably others. To
+ this end, this function takes in the read-only directory as well as the
+ target writable directory, and returns the mount point of the overlay file
+ system.
+
+ Typically the read-only directory will be a ROM's squashfs mount point, and
+ the writable directory will be the ROM's 'saves' path, while the returned
+ mount-point will be /var/run/overlays/<...> (named after the ROM).
+
+ This also means the user is free to manage their saved changes: they can
+ delete them to reset the state of the game or they can back them up, etc.
+ """
+
+ # If we were passed a single file, then overlay its parent directory
+ maybe_rom_file = None
+ if read_only_dir.is_file():
+ _logger.debug("overlaying single-file or linked rom '%s'", read_only_dir)
+ maybe_rom_file = read_only_dir.name
+ read_only_dir = read_only_dir.parent
+
+ # Where overlayfs keeps persistent writes
+ writable_upper_dir = writable_dir / 'upper'
+ writable_upper_dir.mkdir(parents=True, exist_ok=True)
+
+ # Where overlayfs manages in-flight writes
+ writable_work_dir = writable_dir / 'work'
+ writable_work_dir.mkdir(parents=True, exist_ok=True)
+
+ # Where overlayfs exposes the combined filesystem
+ mount_point = ROM_OVERLAY_DIR / read_only_dir.name
+ await _unmount_and_remove(mount_point)
+ mount_point.mkdir(parents=True, exist_ok=True)
+
+ if not await _mount(read_only_dir, writable_upper_dir, writable_work_dir, mount_point):
+ raise BatoceraException(f"Unable to setup writable overlay for '{read_only_dir}'")
+ try:
+ yield (mount_point / maybe_rom_file) if maybe_rom_file else mount_point
+
+ finally:
+ _logger.debug("cleaning up '%s'", mount_point)
+ await _unmount_and_remove(mount_point)
+
+ has_writes = writable_upper_dir.is_dir() and any(writable_upper_dir.iterdir())
+
+ _logger.debug("%s save directory '%s'", 'keeping populated' if has_writes else 'removing empty', writable_dir)
+
+ if not has_writes:
+ shutil.rmtree(writable_dir, ignore_errors=True)
diff --git a/python-src/batocera-launch/batocera_launch/fs/squashfs.py b/python-src/batocera-launch/batocera_launch/fs/squashfs.py
new file mode 100644
index 00000000000..264b4a46e18
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/fs/squashfs.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import logging
+import os
+from contextlib import asynccontextmanager, suppress
+from typing import TYPE_CHECKING
+
+from batocera_common.asyncio import run
+from batocera_common.paths import SQUASHFS_DIR
+
+from ..exceptions import BatoceraException
+from .mounts import unmount
+from .overlayfs import mount_overlayfs
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+ from pathlib import Path
+
+_logger = logging.getLogger(__name__)
+
+
+def _single_child_in_dir(directory: Path, /) -> Path | None:
+ """Return the single child (file or directory) in a directory, or None if there isn't exactly one."""
+ with os.scandir(directory) as entries:
+ first = next(entries, None)
+
+ if first is None or next(entries, None) is not None:
+ return None
+
+ return directory / first.name
+
+
+@asynccontextmanager
+async def _mount_squashfs(rom: Path, /) -> AsyncGenerator[Path]:
+ _logger.debug('mounting: %s', rom)
+ mount_point = SQUASHFS_DIR / rom.stem
+
+ SQUASHFS_DIR.mkdir(parents=True, exist_ok=True)
+
+ # first, try to clean an empty remaining directory (for example because of a crash)
+ if mount_point.exists() and mount_point.is_dir():
+ _logger.debug('%s already exists', mount_point)
+
+ # a previous run may have left the rom mounted here, after a crash or an unmount
+ # that was refused: take it down rather than running the game off a stale mount,
+ # which isn't necessarily even the same rom
+ if mount_point.is_mount():
+ _logger.debug('%s is still mounted, unmounting it first', mount_point)
+ if not await unmount(mount_point):
+ raise BatoceraException(f'Unable to unmount the file {mount_point}')
+
+ # whatever is left is not ours to run the game off: it isn't necessarily even
+ # the same rom, and it would never be unmounted either
+ try:
+ mount_point.rmdir()
+ except FileNotFoundError:
+ pass
+ except OSError as e:
+ raise BatoceraException(f'Unable to clean the mount point {mount_point}') from e
+
+ # ok, the base directory doesn't exist, let's create it and mount the squashfs on it
+ mount_point.mkdir()
+
+ try:
+ await run('mount', rom, mount_point, text=True, check=True)
+ except Exception as e:
+ _logger.exception('mounting %s failed', mount_point)
+
+ try:
+ mount_point.rmdir()
+ except OSError:
+ pass
+
+ raise BatoceraException(f'Unable to mount the file {rom}') from e
+
+ try:
+ # if the squashfs contains a single file with the same name, take it as the rom file
+ if (rom_single := _single_child_in_dir(mount_point)) is not None and rom_single.name == rom.stem:
+ _logger.debug('single rom %s', rom_single)
+ yield rom_single
+ else:
+ try:
+ rom_linked = (mount_point / '.ROM').resolve(strict=True)
+ except OSError:
+ yield mount_point
+ else:
+ _logger.debug('linked rom %s', rom_linked)
+ yield rom_linked
+ finally:
+ _logger.debug('cleaning up %s', mount_point)
+
+ # unmount
+ if not await unmount(mount_point):
+ _logger.debug('unmounting %s failed', mount_point)
+ raise BatoceraException(f'Unable to unmount the file {mount_point}')
+
+ # cleaning the empty directory, a lazily detached mount may still hold it
+ with suppress(OSError):
+ mount_point.rmdir()
+
+
+@asynccontextmanager
+async def mount_squashfs(rom: Path, /, *, writable_dir: Path | None = None) -> AsyncGenerator[Path]:
+ async with _mount_squashfs(rom) as mount_point:
+ if writable_dir is None:
+ yield mount_point
+ else:
+ async with mount_overlayfs(mount_point, writable_dir) as overlay_mount_point:
+ yield overlay_mount_point
diff --git a/python-src/batocera-launch/batocera_launch/joysticks_hotkeys.py b/python-src/batocera-launch/batocera_launch/joysticks_hotkeys.py
new file mode 100644
index 00000000000..f3dd89c26b4
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/joysticks_hotkeys.py
@@ -0,0 +1,347 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any, Final, NamedTuple
+
+from batocera_common.paths import CONFIGS
+
+from .paths import EVMAPY_SHARE_DIR
+
+_SYSTEM_HOTKEYS_FILE: Final = EVMAPY_SHARE_DIR / 'hotkeys.keys'
+_USER_HOTKEYS_FILE: Final = CONFIGS / 'hotkeys.keys'
+_HOTKEYGEN_MAPPING = Path('/etc/hotkeygen/default_mapping.conf')
+
+
+class HotkeysMapping(NamedTuple):
+ by_keys: dict[str, str]
+ by_names: dict[str, str]
+
+
+def _read_config(
+ system_config_file: Path,
+ user_config_file: Path,
+ system_only: bool = False,
+ /,
+) -> dict[str, Any]:
+ if user_config_file.exists() and not system_only:
+ try:
+ return json.loads(user_config_file.read_text())
+ except Exception:
+ print(f'Unable to read user file {user_config_file}', file=sys.stderr)
+
+ return json.loads(system_config_file.read_text())
+
+
+def _read_hotkey_mapping(hotkey_mapping_file: Path, /) -> HotkeysMapping:
+ by_keys = json.loads(hotkey_mapping_file.read_text())
+
+ non_game_hotkeys = ['KEY_FILE']
+
+ # remove hotkeys not for games
+ for k in non_game_hotkeys:
+ if k in by_keys:
+ del by_keys['KEY_FILE']
+
+ by_names: dict[str, str] = {}
+ for m in by_keys:
+ by_names[by_keys[m]] = m
+
+ return HotkeysMapping(by_keys=by_keys, by_names=by_names)
+
+
+def _list_hotkeys(
+ config: dict[str, Any],
+ default_config: dict[str, Any],
+ hotkeys_mapping: HotkeysMapping,
+ /,
+) -> None:
+ if sys.stdout.isatty():
+ _list_hotkeys_text(config, default_config, hotkeys_mapping)
+ else:
+ _list_hotkeys_xml(config, default_config, hotkeys_mapping)
+
+
+def _is_simple_key(key: dict[str, Any], /) -> bool:
+ return (
+ isinstance(key['trigger'], list)
+ and len(key['trigger']) == 2 # pyright: ignore
+ and key['trigger'][0] == 'hotkey'
+ and key['type'] == 'key'
+ and isinstance(key['target'], list)
+ and len(key['target']) == 1 # pyright: ignore
+ )
+
+
+def _get_keys_from_config(config: dict[str, Any], /) -> dict[str, Any]:
+ keys: dict[str, Any] = {}
+ for key in config['actions_player1']:
+ if _is_simple_key(key):
+ btn = key['trigger'][1]
+ action = key['target'][0]
+ keys[btn] = {'action': action}
+ return keys
+
+
+def _list_hotkeys_xml(
+ config: dict[str, Any],
+ default_config: dict[str, Any],
+ hotkeys_mapping: HotkeysMapping,
+ /,
+) -> None:
+ keys = _get_keys_from_config(config)
+ default_keys = _get_keys_from_config(default_config)
+ order = [
+ 'start',
+ 'select',
+ 'up',
+ 'down',
+ 'left',
+ 'right',
+ 'a',
+ 'b',
+ 'x',
+ 'y',
+ 'pageup',
+ 'pagedown',
+ 'l2',
+ 'r2',
+ 'l3',
+ 'r3',
+ ]
+
+ sorted_keys: dict[str, Any] = {}
+ for k in order:
+ if k in keys:
+ sorted_keys[k] = keys[k]
+ else:
+ sorted_keys[k] = None
+ for k in keys:
+ if k not in sorted_keys:
+ sorted_keys[k] = keys[k]
+
+ print('')
+ for btn in sorted_keys:
+ action_name = ''
+ default_action_name = ''
+
+ if btn in keys:
+ action_name = f'unknown ({keys[btn]["action"]})'
+ if keys[btn]['action'] in hotkeys_mapping.by_keys:
+ action_name = hotkeys_mapping.by_keys[keys[btn]['action']]
+
+ if btn in default_keys:
+ default_action_name = f'unknown ({default_keys[btn]["action"]})'
+ if default_keys[btn]['action'] in hotkeys_mapping.by_keys:
+ default_action_name = hotkeys_mapping.by_keys[default_keys[btn]['action']]
+
+ print(f' ')
+
+ print('')
+
+
+def _list_hotkeys_text(
+ config: dict[str, Any],
+ default_config: dict[str, Any],
+ hotkeys_mapping: HotkeysMapping,
+ /,
+) -> None:
+ keys = _get_keys_from_config(config)
+ default_keys = _get_keys_from_config(default_config)
+ order = [
+ 'start',
+ 'select',
+ 'up',
+ 'down',
+ 'left',
+ 'right',
+ 'a',
+ 'b',
+ 'x',
+ 'y',
+ 'pageup',
+ 'pagedown',
+ 'l2',
+ 'r2',
+ 'l3',
+ 'r3',
+ ]
+
+ sorted_keys: dict[str, Any] = {}
+ for k in order:
+ if k in keys:
+ sorted_keys[k] = keys[k]
+ else:
+ sorted_keys[k] = None
+ for k in keys:
+ if k not in sorted_keys:
+ sorted_keys[k] = keys[k]
+
+ for btn in sorted_keys:
+ action_name = None
+ default_action_name = None
+
+ if btn in keys:
+ action_name = f'unknown ({keys[btn]["action"]})'
+ if keys[btn]['action'] in hotkeys_mapping.by_keys:
+ action_name = hotkeys_mapping.by_keys[keys[btn]['action']]
+
+ if btn in default_keys:
+ default_action_name = f'unknown ({default_keys[btn]["action"]})'
+ if default_keys[btn]['action'] in hotkeys_mapping.by_keys:
+ default_action_name = hotkeys_mapping.by_keys[default_keys[btn]['action']]
+
+ if btn == 'pageup':
+ btn = 'l'
+ if btn == 'pagedown':
+ btn = 'r'
+
+ help = ''
+ if action_name != default_action_name:
+ help = f' (default: {default_action_name})'
+ print(f'hotkey + {btn:6} : {action_name}{help}')
+
+
+def _update_hotkeys(
+ config: dict[str, Any],
+ new_keys: dict[str, Any],
+ user_config_file: Path,
+ default_config: dict[str, Any],
+ /,
+) -> None:
+ # update keys
+ for new_key in new_keys:
+ if gdebug:
+ print(f'updating key {new_key}', file=sys.stderr)
+ found = False
+ for index, key in enumerate(config['actions_player1']):
+ if _is_simple_key(key) and key['trigger'][1] == new_key:
+ if gdebug:
+ print(f'key {new_key} already set. reaffecting it.', file=sys.stderr)
+ found = True
+ if new_keys[new_key] == 'none':
+ del config['actions_player1'][index]
+ else:
+ if new_keys[new_key] == 'default':
+ # get default
+ for default_key in default_config['actions_player1']:
+ if _is_simple_key(default_key) and default_key['trigger'][1] == new_key:
+ config['actions_player1'][index]['target'] = default_key['target']
+ else:
+ config['actions_player1'][index]['target'] = [new_keys[new_key]]
+ # the key was removed, add it back from default config
+ if not found:
+ if gdebug:
+ print(f'key {new_key} is not defined. affecting it.', file=sys.stderr)
+ if new_keys[new_key] != 'none':
+ found = False
+ for key in default_config['actions_player1']:
+ if _is_simple_key(key) and key['trigger'][1] == new_key:
+ # find the default key and append it
+ found = True
+ print(f'key {new_key} initialized with default value, before reaffecting.', file=sys.stderr)
+ if new_keys[new_key] != 'default':
+ key['target'] = [new_keys[new_key]]
+ config['actions_player1'].append(key)
+ if not found and new_keys[new_key] != 'default':
+ print(f'key {new_key} has no default value. affect a default mapping.', file=sys.stderr)
+ key = {'trigger': ['hotkey', new_key], 'type': 'key', 'target': [new_keys[new_key]]}
+ config['actions_player1'].append(key)
+ # save
+ user_config_file.write_text(json.dumps(config, indent=4))
+
+
+def _list_values(by_names: dict[str, str], /) -> None:
+ for key in sorted(by_names):
+ print(key)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(prog='batocera-joysticks-hotkeys')
+ parser.add_argument('--values', action='store_true', help='list possible values. none and default can be used too.')
+ parser.add_argument('--start', type=str, help='key for hotkey+start')
+ parser.add_argument('--select', type=str, help='key for hotkey+select')
+ parser.add_argument('--up', type=str, help='key for hotkey+up')
+ parser.add_argument('--down', type=str, help='key for hotkey+down')
+ parser.add_argument('--left', type=str, help='key for hotkey+left')
+ parser.add_argument('--right', type=str, help='key for hotkey+right')
+ parser.add_argument('--a', type=str, help='key for hotkey+a')
+ parser.add_argument('--b', type=str, help='key for hotkey+b')
+ parser.add_argument('--x', type=str, help='key for hotkey+x')
+ parser.add_argument('--y', type=str, help='key for hotkey+y')
+ parser.add_argument('--l', type=str, help='key for hotkey+l')
+ parser.add_argument('--r', type=str, help='key for hotkey+r')
+ parser.add_argument('--pageup', type=str, help='key for hotkey+l')
+ parser.add_argument('--pagedown', type=str, help='key for hotkey+r')
+ parser.add_argument('--l2', type=str, help='key for hotkey+l2')
+ parser.add_argument('--r2', type=str, help='key for hotkey+r2')
+ parser.add_argument('--l3', type=str, help='key for hotkey+l3')
+ parser.add_argument('--r3', type=str, help='key for hotkey+r3')
+ parser.add_argument('--debug', action='store_true')
+
+ args = parser.parse_args()
+ new_keys: dict[str, str] = {}
+
+ if args.start:
+ new_keys['start'] = args.start
+ if args.select:
+ new_keys['select'] = args.select
+ if args.up:
+ new_keys['up'] = args.up
+ if args.down:
+ new_keys['down'] = args.down
+ if args.left:
+ new_keys['left'] = args.left
+ if args.right:
+ new_keys['right'] = args.right
+ if args.a:
+ new_keys['a'] = args.a
+ if args.b:
+ new_keys['b'] = args.b
+ if args.x:
+ new_keys['x'] = args.x
+ if args.y:
+ new_keys['y'] = args.y
+ if args.l:
+ new_keys['pageup'] = args.l
+ if args.r:
+ new_keys['pagedown'] = args.r
+ if args.pageup:
+ new_keys['pageup'] = args.pageup
+ if args.pagedown:
+ new_keys['pagedown'] = args.pagedown
+ if args.l2:
+ new_keys['l2'] = args.l2
+ if args.r2:
+ new_keys['r2'] = args.r2
+ if args.l3:
+ new_keys['l3'] = args.l3
+ if args.r3:
+ new_keys['r3'] = args.r3
+
+ global gdebug
+ gdebug = args.debug
+ hotkeys_mapping = _read_hotkey_mapping(_HOTKEYGEN_MAPPING)
+
+ if args.values:
+ _list_values(hotkeys_mapping.by_names)
+ exit(0)
+
+ default_config = _read_config(_SYSTEM_HOTKEYS_FILE, _USER_HOTKEYS_FILE, True)
+ config = _read_config(_SYSTEM_HOTKEYS_FILE, _USER_HOTKEYS_FILE)
+
+ # convert new keys
+ for k in new_keys:
+ if new_keys[k] in hotkeys_mapping.by_names:
+ new_keys[k] = hotkeys_mapping.by_names[new_keys[k]]
+
+ if len(new_keys) == 0:
+ _list_hotkeys(config, default_config, hotkeys_mapping)
+ else:
+ _update_hotkeys(config, new_keys, _USER_HOTKEYS_FILE, default_config)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/python-src/batocera-launch/batocera_launch/launch.py b/python-src/batocera-launch/batocera_launch/launch.py
new file mode 100644
index 00000000000..dfa7601e10d
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/launch.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import logging
+import signal
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, Final
+
+import uvloop
+
+from batocera_common.key_value_config import KeyValueConfig
+from batocera_common.paths import BATOCERA_CONF, BATOCERA_SHARE_DIR
+
+from .exceptions import BaseBatoceraException, BatoceraException
+
+if TYPE_CHECKING:
+ from .cli.arguments import Arguments
+ from .profiler import Profiler
+
+_logger: Final = logging.getLogger(__name__)
+
+
+async def _run(args: Arguments, profiler: Profiler, /) -> int:
+ from .emulator import Emulator
+
+ async with Emulator.create(args, profiler) as emulator:
+ return await emulator.run()
+
+
+def _run_legacy(args: Arguments, profiler: Profiler, /) -> int:
+ from configgen.emulatorlauncher import main # pyright: ignore
+
+ return main(args, profiler)
+
+
+def launch(args: Arguments, profiler: Profiler, /) -> None:
+ batocera_version = 'UNKNOWN'
+ if (version_file := BATOCERA_SHARE_DIR / 'batocera.version').exists():
+ batocera_version = version_file.read_text().strip()
+
+ _logger.info('Batocera version: %s', batocera_version)
+ _logger.debug('Arguments: %s', args)
+
+ exit_code = 0
+ try:
+ config = KeyValueConfig()
+ config.read(BATOCERA_CONF)
+
+ if config.get('configgen') == '1':
+ _logger.debug('Using legacy configgen')
+ exit_code = _run_legacy(args, profiler)
+ else:
+ exit_code = uvloop.run(_run(args, profiler))
+ except BaseBatoceraException as e:
+ _logger.exception('batocera-launch exception')
+ exit_code = e.exit_code
+
+ if isinstance(e, BatoceraException):
+ Path('/tmp/launch_error.log').write_text(e.args[0])
+ except Exception:
+ _logger.exception('batocera-launch exception')
+
+ # this seems to be required so that the gpu memory is resituated and available for ES
+ time.sleep(1)
+
+ if exit_code < 0:
+ signal_number = exit_code * -1
+
+ if signal_number < signal.NSIG:
+ signal_description = signal.strsignal(signal_number)
+
+ if signal_description and ':' not in signal_description:
+ signal_description = f'{signal_description}: {signal_number}'
+
+ _logger.debug('Emulator terminated by signal (%s)', signal_description)
+ exit_code = 0
+
+ _logger.debug('Exiting batocera-launch with status %s', exit_code)
+
+ exit(exit_code)
diff --git a/python-src/batocera-launch/batocera_launch/logging.py b/python-src/batocera-launch/batocera_launch/logging.py
new file mode 100644
index 00000000000..5869365415f
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/logging.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import errno
+import io
+import logging
+import sys
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, TextIO
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
+
+class EpipeTolerantTextIO(io.TextIOBase):
+ _raw: TextIO
+
+ """
+ Wrap a text stream to swallow BrokenPipeError/EPIPE on write/flush.
+ """
+
+ def __init__(self, raw: TextIO) -> None:
+ self._raw = raw
+
+ def write(self, s: str) -> int:
+ try:
+ return self._raw.write(s)
+ except BrokenPipeError:
+ return 0
+ except OSError as e:
+ if e.errno == errno.EPIPE:
+ return 0
+ raise
+
+ def flush(self) -> None:
+ try:
+ return self._raw.flush()
+ except BrokenPipeError:
+ return None
+ except OSError as e:
+ if e.errno == errno.EPIPE:
+ return None
+ raise
+
+ # Delegate other properties/methods
+ @property
+ def encoding(self) -> str: # type: ignore[override] # pyright: ignore[reportIncompatibleVariableOverride]
+ return self._raw.encoding
+
+ def fileno(self) -> int:
+ return self._raw.fileno()
+
+ def isatty(self) -> bool:
+ return self._raw.isatty()
+
+ def close(self) -> None:
+ try:
+ self._raw.close()
+ except BrokenPipeError:
+ pass
+ except OSError as e:
+ if e.errno != errno.EPIPE:
+ raise
+
+ @property
+ def closed(self) -> bool:
+ return self._raw.closed
+
+
+class EpipeTolerantStreamHandler(logging.StreamHandler[EpipeTolerantTextIO]):
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ super().emit(record)
+ except BrokenPipeError:
+ return
+ except OSError as e:
+ if e.errno == errno.EPIPE:
+ return
+ raise
+
+ def flush(self) -> None:
+ try:
+ super().flush()
+ except BrokenPipeError:
+ return
+ except OSError as e:
+ if e.errno == errno.EPIPE:
+ return
+ raise
+
+
+@contextmanager
+def setup_logging() -> Generator[None]:
+ """
+ Configure logging with EPIPE-tolerant stdout/stderr and handlers.
+ - DEBUG..INFO to stdout
+ - WARNING..CRITICAL to stderr
+ Also replaces sys.stdout/sys.stderr to protect non-logging writes.
+ """
+ # Replace sys.stdout/sys.stderr globally to protect any print() or library writes
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+ sys.stdout = EpipeTolerantTextIO(sys.stdout)
+ sys.stderr = EpipeTolerantTextIO(sys.stderr)
+
+ logger = logging.getLogger()
+ original_handlers = list(logger.handlers)
+
+ date_format = '%Y-%m-%d %H:%M:%S'
+ formatter = logging.Formatter(
+ fmt='[{asctime}] [{levelname:<7}] ({name}:{funcName}:{lineno}): {message}',
+ datefmt=date_format,
+ style='{',
+ )
+
+ for h in original_handlers:
+ logger.removeHandler(h)
+
+ error_level = logging.WARNING
+
+ stdout_handler = EpipeTolerantStreamHandler(stream=sys.stdout)
+ stdout_handler.setLevel(logging.DEBUG)
+ stdout_handler.setFormatter(formatter)
+ stdout_handler.addFilter(lambda r: r.levelno < error_level)
+
+ stderr_handler = EpipeTolerantStreamHandler(stream=sys.stderr)
+ stderr_handler.setLevel(error_level)
+ stderr_handler.setFormatter(formatter)
+
+ logger.setLevel(logging.DEBUG)
+ logger.addHandler(stdout_handler)
+ logger.addHandler(stderr_handler)
+
+ try:
+ from batocera_common.paths import LOGS
+
+ file_handler = logging.FileHandler(filename=LOGS / 'batocera-launch.log', encoding='utf-8', mode='w')
+ file_handler.setLevel(logging.NOTSET) # Log all messages to the one file
+ file_handler.setFormatter(formatter)
+ file_handler.addFilter(lambda r: r.name != 'emulator') # Exclude emulator logs from main log
+
+ logger.addHandler(file_handler)
+
+ emulator_file_handler = logging.FileHandler(
+ filename=LOGS / 'batocera-launch-emulator.log', encoding='utf-8', mode='w'
+ )
+ emulator_file_handler.setLevel(logging.NOTSET) # Log all messages to the one file
+ emulator_file_handler.setFormatter(
+ logging.Formatter(
+ # Just log the message and timestamp for emulator logs, no need for function/line info
+ fmt='[{asctime}] {message}',
+ datefmt=date_format,
+ style='{',
+ )
+ )
+ emulator_file_handler.addFilter(lambda r: r.name == 'emulator') # Only include emulator logs
+
+ logger.addHandler(emulator_file_handler)
+ except OSError:
+ logger.exception('Could not set up log files')
+
+ try:
+ yield
+ finally:
+ # Clean up logging handlers
+ for h in logger.handlers[:]:
+ try:
+ h.flush()
+ except Exception:
+ pass
+ h.close()
+ logger.removeHandler(h)
+
+ # Optionally restore prior handlers
+ for h in original_handlers:
+ logger.addHandler(h)
+
+ # Restore original stdout/stderr
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
diff --git a/python-src/batocera-launch/batocera_launch/paths.py b/python-src/batocera-launch/batocera_launch/paths.py
new file mode 100644
index 00000000000..4e133206c54
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/paths.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Final
+
+from batocera_common.paths import BATOCERA_SHARE_DIR, CONFIGS, HOME, USERDATA
+
+DATAINIT_DIR: Final = BATOCERA_SHARE_DIR / 'datainit'
+
+HOME_INIT: Final = DATAINIT_DIR / 'system'
+CONF_INIT: Final = HOME_INIT / 'configs'
+
+EVMAPY: Final = CONFIGS / 'evmapy'
+EVMAPY_SHARE_DIR: Final = Path('/usr/share/evmapy')
+
+LAUNCH_SHARE_DIR: Final = BATOCERA_SHARE_DIR / 'launch'
+LAUNCH_DATA_DIR: Final = LAUNCH_SHARE_DIR / 'data'
+LAUNCH_DEFAULTS_DIR: Final = LAUNCH_SHARE_DIR / 'defaults'
+
+USER_ES_DIR: Final = CONFIGS / 'emulationstation'
+SYSTEM_ES_DIR: Final = Path('/usr/share/emulationstation')
+
+ES_SETTINGS: Final = USER_ES_DIR / 'es_settings.cfg'
+
+ES_RESOURCES_DIR: Final = SYSTEM_ES_DIR / 'resources'
+
+ES_GUNS_METADATA: Final = ES_RESOURCES_DIR / 'gungames.xml'
+ES_WHEELS_METADATA: Final = ES_RESOURCES_DIR / 'wheelgames.xml'
+ES_GAMES_METADATA: Final = ES_RESOURCES_DIR / 'gamesdb.xml'
+ES_GUNS_ART_METADATA: Final = LAUNCH_DATA_DIR / 'gamesbuttonsdb.xml'
+
+DEFAULTS_DIR: Final = LAUNCH_SHARE_DIR / 'defaults'
+
+USER_SHADERS: Final = USERDATA / 'shaders'
+BATOCERA_SHADERS: Final = BATOCERA_SHARE_DIR / 'shaders'
+
+USER_DECORATIONS: Final = USERDATA / 'decorations'
+SYSTEM_DECORATIONS: Final = DATAINIT_DIR / 'decorations'
+
+USER_SCRIPTS: Final = HOME / 'scripts'
+SYSTEM_SCRIPTS: Final = LAUNCH_SHARE_DIR / 'scripts'
+
+PRECALIBRATION_DIR: Final = BATOCERA_SHARE_DIR / 'guns-precalibration'
+
+
+def configure_emulator(rom: Path, /) -> bool:
+ return str(rom) == 'config'
diff --git a/python-src/batocera-launch/batocera_launch/profiler.py b/python-src/batocera-launch/batocera_launch/profiler.py
new file mode 100644
index 00000000000..656f51a4aac
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/profiler.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from contextlib import AbstractContextManager, contextmanager
+from cProfile import Profile
+from dataclasses import InitVar, dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Self
+
+if TYPE_CHECKING:
+ from _typeshed import StrPath
+ from collections.abc import Generator
+ from types import TracebackType
+
+
+@dataclass(slots=True)
+class Profiler(AbstractContextManager['Profiler']):
+ sentinel: InitVar[StrPath]
+
+ _sentinel_path: Path = field(init=False)
+ _profile: Profile | None = field(init=False, default=None)
+
+ def __post_init__(self, sentinel: StrPath) -> None:
+ self._sentinel_path = Path(sentinel)
+ if self._sentinel_path.exists():
+ self._profile = Profile()
+
+ def __enter__(self) -> Self:
+ if self._profile:
+ self._profile.enable()
+ return self
+
+ def __exit__(
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
+ ) -> None:
+ if self._profile:
+ self._profile.disable()
+ self._profile.dump_stats(self._sentinel_path.with_suffix('.prof'))
+
+ @contextmanager
+ def pause(self) -> Generator[None]:
+ if not self._profile:
+ yield
+ return
+
+ self._profile.disable()
+ try:
+ yield
+ finally:
+ self._profile.enable()
diff --git a/python-src/batocera-launch/batocera_launch/py.typed b/python-src/batocera-launch/batocera_launch/py.typed
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/python-src/batocera-launch/batocera_launch/rom.py b/python-src/batocera-launch/batocera_launch/rom.py
new file mode 100644
index 00000000000..a1715bf8f49
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/rom.py
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from pathlib import Path, PurePath
+from typing import TYPE_CHECKING, Self, overload
+
+from .fs.squashfs import mount_squashfs
+
+if TYPE_CHECKING:
+ from _typeshed import StrPath
+ from collections.abc import AsyncGenerator, Callable, Generator, Iterator, Sequence
+
+
+def _short_name_from_path(path: str | Path) -> str:
+ redname = Path(path).stem.lower()
+ inpar = False
+ inblock = False
+ ret = ''
+ for c in redname:
+ if not inpar and not inblock and ((c >= 'a' and c <= 'z') or (c >= '0' and c <= '9')):
+ ret += c
+ elif c == '(':
+ inpar = True
+ elif c == ')':
+ inpar = False
+ elif c == '[':
+ inblock = True
+ elif c == ']':
+ inblock = False
+ return ret
+
+
+class Rom(Path):
+ __slots__ = ('_prepared', '_source')
+
+ _source: Path
+ _prepared: Path | None
+
+ def __new__(cls, source: Path, prepared: Path | None, /) -> Self:
+ return super().__new__(cls)
+
+ def __init__(self, source: Path, prepared: Path | None, /) -> None:
+ super().__init__(source if prepared is None else prepared)
+ self._source = source
+ self._prepared = prepared
+
+ @property
+ def id(self) -> str:
+ return self._source.stem
+
+ @property
+ def short_id(self) -> str:
+ return _short_name_from_path(self._source)
+
+ @property
+ def source(self) -> Path:
+ return self._source
+
+ @property
+ def prepared(self) -> Path | None:
+ return self._prepared
+
+ @property
+ def parent(self) -> Path:
+ return Path(super().parent)
+
+ def with_segments(self, *args: StrPath) -> Path:
+ return Path(*args)
+
+ if TYPE_CHECKING:
+ # Since we override with_segments() to always return a path (rather than Rom), we
+ # have to make sure the type-checker knows that's what's going to happen. These
+ # are all of the methods from PurePath and Path that return Self changed to
+ # return Path.
+
+ def __truediv__(self, key: StrPath) -> Path: ...
+ def __rtruediv__(self, key: StrPath) -> Path: ...
+ def relative_to(self, other: StrPath, *, walk_up: bool = False) -> Path: ...
+ def with_name(self, name: str) -> Path: ...
+ def with_stem(self, stem: str) -> Path: ...
+ def with_suffix(self, suffix: str) -> Path: ...
+ def joinpath(self, *other: StrPath) -> Path: ...
+
+ @property
+ def parents(self) -> Sequence[Path]: ...
+
+ def glob(
+ self,
+ pattern: str,
+ *,
+ case_sensitive: bool | None = None,
+ recurse_symlinks: bool = False,
+ ) -> Iterator[Path]: ...
+ def rglob(
+ self,
+ pattern: str,
+ *,
+ case_sensitive: bool | None = None,
+ recurse_symlinks: bool = False,
+ ) -> Iterator[Path]: ...
+ def iterdir(self) -> Generator[Path]: ...
+ @overload
+ def move_into[P: PurePath](self, target_dir: P) -> P: ... # pyright: ignore[reportNoOverloadImplementation, reportOverlappingOverload]
+ @overload
+ def move_into(self, target_dir: StrPath) -> Path: ...
+ @overload
+ def move[P: PurePath](self, target: P) -> P: ... # pyright: ignore[reportNoOverloadImplementation, reportOverlappingOverload]
+ @overload
+ def move(self, target: StrPath) -> Path: ...
+ @overload
+ def copy_into[P: PurePath]( # pyright: ignore[reportNoOverloadImplementation, reportOverlappingOverload]
+ self,
+ target_dir: P,
+ *,
+ follow_symlinks: bool = True,
+ preserve_metadata: bool = False,
+ ) -> P: ...
+ @overload
+ def copy_into(
+ self,
+ target_dir: StrPath,
+ *,
+ follow_symlinks: bool = True,
+ preserve_metadata: bool = False,
+ ) -> Path: ...
+ @overload
+ def copy[P: PurePath]( # pyright: ignore[reportNoOverloadImplementation, reportOverlappingOverload]
+ self,
+ target: P,
+ *,
+ follow_symlinks: bool = True,
+ preserve_metadata: bool = False,
+ ) -> P: ...
+ @overload
+ def copy(
+ self,
+ target: StrPath,
+ *,
+ follow_symlinks: bool = True,
+ preserve_metadata: bool = False,
+ ) -> Path: ...
+ def readlink(self) -> Path: ...
+ def rename(self, target: StrPath) -> Path: ...
+ def replace(self, target: StrPath) -> Path: ...
+ def resolve(self, strict: bool = False) -> Path: ...
+ def absolute(self) -> Path: ...
+ def expanduser(self) -> Path: ...
+ def walk(
+ self,
+ top_down: bool = True,
+ on_error: Callable[[OSError], object] | None = None,
+ follow_symlinks: bool = False,
+ ) -> Iterator[tuple[Path, list[str], list[str]]]: ...
+
+ @classmethod
+ def cwd(cls) -> Path:
+ return Path.cwd()
+
+ @classmethod
+ @asynccontextmanager
+ async def prepare(cls, source: Path, /, *, writable_dir: Path | None = None) -> AsyncGenerator[Self]:
+ if source.suffix == '.squashfs':
+ async with mount_squashfs(source, writable_dir=writable_dir) as prepared:
+ yield cls(source, prepared)
+ else:
+ yield cls(source, None)
diff --git a/python-src/batocera-launch/batocera_launch/types.py b/python-src/batocera-launch/batocera_launch/types.py
new file mode 100644
index 00000000000..6ce34f99de4
--- /dev/null
+++ b/python-src/batocera-launch/batocera_launch/types.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Self, TypedDict, cast
+
+_logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping, Sequence
+ from pathlib import Path
+
+
+@dataclass(slots=True, frozen=True)
+class Resolution:
+ width: int
+ height: int
+
+
+@dataclass(slots=True, frozen=True)
+class ScreenInfo:
+ name: str
+ resolution: Resolution
+ x: int
+ y: int
+
+ @classmethod
+ def from_configgen(cls, *infos: Mapping[str, Any]) -> list[Self]:
+ return [
+ cls(
+ name=info['name'],
+ resolution=Resolution(width=info['width'], height=info['height']),
+ x=info['x'],
+ y=info['y'],
+ )
+ for info in infos
+ ]
+
+
+class HotkeysContext(TypedDict):
+ name: str
+ keys: dict[str, Sequence[str]] # Sequence[str] covers both str and list[str]
+
+
+@dataclass(slots=True, frozen=True)
+class BezelFiles:
+ png: Path
+ info: Path | None = None
+ layout: Path | None = None
+ mame_zip: Path | None = None
+ specific_to_game: bool = False
+
+
+@dataclass(slots=True, frozen=True)
+class BezelInfo:
+ width: int | None
+ height: int | None
+ top: int | None
+ bottom: int | None
+ left: int | None
+ right: int | None
+ opacity: float | None
+ message_x: float | None
+ message_y: float | None
+
+ @classmethod
+ def load_from_json(cls, path: Path | None, /) -> Self:
+ info: dict[str, Any] = {}
+
+ if path is not None and path.exists():
+ try:
+ info = cast('dict[str, Any]', json.loads(path.read_text()))
+ except Exception:
+ _logger.warning('Unable to read %s', path)
+
+ if 'width' in info and 'height' in info:
+ _logger.info('Bezel size read from %s', path)
+
+ return cls(
+ width=info.get('width'),
+ height=info.get('height'),
+ top=info.get('top'),
+ bottom=info.get('bottom'),
+ left=info.get('left'),
+ right=info.get('right'),
+ opacity=info.get('opacity'),
+ message_x=info.get('messagex'),
+ message_y=info.get('messagey'),
+ )
diff --git a/python-src/batocera-launch/pyproject.toml b/python-src/batocera-launch/pyproject.toml
new file mode 100644
index 00000000000..efe3edf594f
--- /dev/null
+++ b/python-src/batocera-launch/pyproject.toml
@@ -0,0 +1,90 @@
+[project]
+name = "batocera-launch"
+version = '44.0'
+dependencies = [
+ 'uvloop',
+ 'batocera-common',
+]
+
+[project.scripts]
+batocera-launch = "batocera_launch.cli.main:main"
+emulatorlauncher = "batocera_launch.cli.main:main"
+batocera-joysticks-hotkeys = "batocera_launch.joysticks_hotkeys:main"
+
+[project.entry-points."batocera_launch.emulators"]
+lexaloffle = 'batocera_launch.emulators.lexaloffle:Lexaloffle'
+sh = 'batocera_launch.emulators.sh:Sh'
+
+abuse = 'batocera_launch.emulators.abuse:Abuse'
+applewin = 'batocera_launch.emulators.applewin:AppleWin'
+azahar = 'batocera_launch.emulators.azahar:Azahar'
+bstone = 'batocera_launch.emulators.bstone:BStone'
+cannonball = 'batocera_launch.emulators.cannonball:Cannonball'
+catacombgl = 'batocera_launch.emulators.catacombgl:CatacombGL'
+clk = 'batocera_launch.emulators.clk:Clk'
+corsixth = 'batocera_launch.emulators.corsixth:CorsixTH'
+devilutionx = 'batocera_launch.emulators.devilutionx:DevilutionX'
+dhewm3 = 'batocera_launch.emulators.dhewm3:Dhewm3'
+dosbox = 'batocera_launch.emulators.dosbox:DosBox'
+dosbox_staging = 'batocera_launch.emulators.dosbox_staging:DosBoxStaging'
+dosboxx = 'batocera_launch.emulators.dosboxx:DosBoxx'
+dxx-rebirth = 'batocera_launch.emulators.dxx_rebirth:DXXRebirth'
+easyrpg = 'batocera_launch.emulators.easyrpg:EasyRPG'
+ecwolf = 'batocera_launch.emulators.ecwolf:ECWolf'
+eduke32 = 'batocera_launch.emulators.eduke32:EDuke32'
+etlegacy = 'batocera_launch.emulators.etlegacy:ETLegacy'
+fba2x = 'batocera_launch.emulators.fba2x:Fba2x'
+flatpak = 'batocera_launch.emulators.flatpak:Flatpak'
+gsplus = 'batocera_launch.emulators.gsplus:GSplus'
+hatari = 'batocera_launch.emulators.hatari:Hatari'
+hcl = 'batocera_launch.emulators.hcl:Hcl'
+hurrican = 'batocera_launch.emulators.hurrican:Hurrican'
+ikemen = 'batocera_launch.emulators.ikemen:Ikemen'
+ioquake3 = 'batocera_launch.emulators.ioquake3:IOQuake3'
+iortcw = 'batocera_launch.emulators.iortcw:IORTCW'
+jazz2-native = 'batocera_launch.emulators.jazz2_native:Jazz2_Native'
+lightspark = 'batocera_launch.emulators.lightspark:Lightspark'
+nanoboyadvance = 'batocera_launch.emulators.nanoboyadvance:Nanoboyadvance'
+odcommander = 'batocera_launch.emulators.odcommander:ODCommander'
+pygame = 'batocera_launch.emulators.pygame:Pygame'
+pyxel = 'batocera_launch.emulators.pyxel:Pyxel'
+raze = 'batocera_launch.emulators.raze:Raze'
+redream = 'batocera_launch.emulators.redream:Redream'
+ruffle = 'batocera_launch.emulators.ruffle:Ruffle'
+samcoupe = 'batocera_launch.emulators.samcoupe:Samcoupe'
+scummvm = 'batocera_launch.emulators.scummvm:ScummVM'
+sdlpop = 'batocera_launch.emulators.sdlpop:SdlPop'
+solarus = 'batocera_launch.emulators.solarus:Solarus'
+sonic-mania = 'batocera_launch.emulators.sonic_mania:SonicMania'
+sonic2013 = 'batocera_launch.emulators.sonicretro:SonicRetro'
+sonic3-air = 'batocera_launch.emulators.sonic3_air:Sonic3AIR'
+soniccd = 'batocera_launch.emulators.sonicretro:SonicRetro'
+steam = 'batocera_launch.emulators.steam:Steam'
+stella = 'batocera_launch.emulators.stella:Stella'
+taradino = 'batocera_launch.emulators.taradino:Taradino'
+theforceengine = 'batocera_launch.emulators.theforceengine:TheForceEngine'
+thextech = 'batocera_launch.emulators.thextech:TheXTech'
+tic80 = 'batocera_launch.emulators.tic80:Tic80'
+trx = 'batocera_launch.emulators.trx:TRX'
+tsugaru = 'batocera_launch.emulators.tsugaru:Tsugaru'
+tyrian = 'batocera_launch.emulators.tyrian:Tyrian'
+uqm = 'batocera_launch.emulators.uqm:Uqm'
+vkquake = 'batocera_launch.emulators.vkquake:VKQuake'
+vkquake2 = 'batocera_launch.emulators.vkquake2:VKQuake2'
+vkquake3 = 'batocera_launch.emulators.ioquake3:IOQuake3'
+x16emu = 'batocera_launch.emulators.x16emu:X16emu'
+xash3d_fwgs = 'batocera_launch.emulators.xash3d_fwgs:Xash3dFwgs'
+xroar = 'batocera_launch.emulators.xroar:Xroar'
+yquake2 = 'batocera_launch.emulators.yquake2:YQuake2'
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+include = ["/batocera_launch"]
+
+[tool.pytest]
+minversion = "9.0"
+addopts = ["--import-mode=importlib", "--cov=batocera_launch", "--cov-branch", "--cov-report=term"]
+strict = true
diff --git a/package/batocera/core/batocera-configgen/data/gamesbuttonsdb.xml b/python-src/batocera-launch/resources/data/gamesbuttonsdb.xml
similarity index 100%
rename from package/batocera/core/batocera-configgen/data/gamesbuttonsdb.xml
rename to python-src/batocera-launch/resources/data/gamesbuttonsdb.xml
diff --git a/python-src/batocera-launch/resources/data/special/3ds.toml b/python-src/batocera-launch/resources/data/special/3ds.toml
new file mode 100644
index 00000000000..05c43e2b4da
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/3ds.toml
@@ -0,0 +1,4 @@
+'0357 - New Love Plus (Japan)' = '90'
+New_Love_Plus_JPN_3DS-Kirin = '90'
+'0680 - New Love Plus+ (Japan)' = '90'
+New_Love_Plus_Plus_JPN_3DS-Kirin = '90'
diff --git a/python-src/batocera-launch/resources/data/special/atomiswave.toml b/python-src/batocera-launch/resources/data/special/atomiswave.toml
new file mode 100644
index 00000000000..8967d5a03f7
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/atomiswave.toml
@@ -0,0 +1,25 @@
+anmlbskta = '270'
+anmlbskt = '270'
+blokpong = '270'
+ikaruga = '270'
+illvelo = '270'
+karous = '270'
+mamonoro = '270'
+psyvar2 = '270'
+quizqgd = '270'
+radirgy = '270'
+radirgyo = '270'
+shikgam2 = '270'
+sl2007 = '270'
+shorse = '270'
+starhrcl = '270'
+shors2k1 = '270'
+shorsep = '270'
+starhrsp = '270'
+shorsepr = '270'
+starhrpr = '270'
+trghearto = '270'
+trgheart = '270'
+trizeal = '270'
+undefeat = '270'
+waidrive = '270'
diff --git a/python-src/batocera-launch/resources/data/special/dice.toml b/python-src/batocera-launch/resources/data/special/dice.toml
new file mode 100644
index 00000000000..6a0105d1aee
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/dice.toml
@@ -0,0 +1,2 @@
+breakout = '270'
+pinpong = '270'
diff --git a/python-src/batocera-launch/resources/data/special/fbneo.toml b/python-src/batocera-launch/resources/data/special/fbneo.toml
new file mode 100644
index 00000000000..42d19df01b8
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/fbneo.toml
@@ -0,0 +1,2957 @@
+1941 = '270'
+1941j = '270'
+1941r1 = '270'
+1941u = '270'
+1942 = '270'
+1942a = '270'
+1942abl = '270'
+1942b = '270'
+1942h = '270'
+1942p = '270'
+1942w = '270'
+1943 = '270'
+1943b = '270'
+1943bj = '270'
+1943j = '270'
+1943ja = '270'
+1943jah = '270'
+1943kai = '270'
+1943mii = '270'
+1943u = '270'
+1943ua = '270'
+1945kiii = '270'
+1945kiiin = '270'
+1945kiiio = '270'
+19in1 = '90'
+19xx = '270'
+19xxa = '270'
+19xxar1 = '270'
+19xxb = '270'
+19xxd = '270'
+19xxh = '270'
+19xxj = '270'
+19xxjr1 = '270'
+19xxjr2 = '270'
+19xxu = '270'
+20pacgal = '90'
+20pacgalr0 = '90'
+20pacgalr1 = '90'
+20pacgalr2 = '90'
+20pacgalr3 = '90'
+20pacgalr4 = '90'
+25pacman = '90'
+25pacmano = '90'
+39in1 = '90'
+48in1 = '90'
+48in1a = '90'
+48in1b = '90'
+48in1c = '90'
+4in1 = '90'
+4in1a = '90'
+4in1b = '90'
+5 = '270'
+600 = '90'
+60in1 = '90'
+800fath = '90'
+800fatha = '90'
+8ball = '90'
+8ball1 = '90'
+8ballact = '270'
+8ballact2 = '270'
+8bpm = '90'
+99lstwar = '90'
+99lstwara = '90'
+99lstwarb = '90'
+99lstwark = '90'
+aafb = '270'
+aafbb = '270'
+aafbc = '270'
+aafbd2p = '270'
+abattle = '90'
+abattle2 = '90'
+abscam = '90'
+aceattac = '270'
+aceattaca = '270'
+acombat = '90'
+acombat3 = '90'
+acombat4 = '90'
+acombato = '90'
+acrobatm = '270'
+ad2083 = '90'
+aerfboo2 = '270'
+aerfboot = '270'
+aerofgt = '270'
+aerofgtb = '270'
+aerofgtc = '270'
+aerofgts = '270'
+afighter = '270'
+afightera = '270'
+afighterb = '270'
+afighterc = '270'
+afighterd = '270'
+afightere = '270'
+afighterf = '270'
+afighterg = '270'
+afighterh = '270'
+afire = '90'
+agallet = '270'
+agalleta = '270'
+agalletah = '270'
+agalletaj = '270'
+agalletak = '270'
+agalletat = '270'
+agalletau = '270'
+agalleth = '270'
+agalletj = '270'
+agalletk = '270'
+agallett = '270'
+agalletu = '270'
+airass = '270'
+airattck = '270'
+airattcka = '270'
+airduel = '270'
+airdueljm72 = '270'
+airduelm72 = '270'
+airduelu = '270'
+airraid = '270'
+ajax = '90'
+ajaxj = '90'
+akkanvdr = '270'
+alcon = '270'
+alibaba = '90'
+alibabab = '90'
+alieninv = '270'
+alieninvp2 = '270'
+alinvade = '90'
+alleymas = '270'
+alphaho = '270'
+alphahob = '270'
+alphamis = '270'
+alphaxz = '90'
+alpine = '270'
+alpinea = '270'
+altair = '90'
+amatelas = '270'
+amazon = '270'
+amazont = '270'
+amidar = '90'
+amidar1 = '90'
+amidarb = '90'
+amidarc = '90'
+amidaro = '90'
+amidars = '90'
+amidaru = '90'
+amigo = '90'
+amigo2 = '90'
+androidp = '90'
+androidpo = '90'
+andromed = '270'
+angelkds = '90'
+anmlbskt = '270'
+anmlbskta = '270'
+anteater = '90'
+anteaterg = '90'
+anteatergg = '90'
+anteateruk = '90'
+apb = '270'
+apb1 = '270'
+apb2 = '270'
+apb3 = '270'
+apb4 = '270'
+apb5 = '270'
+apb6 = '270'
+apbf = '270'
+apbg = '270'
+aponow = '90'
+arabian = '270'
+arabiana = '270'
+aracnis = '90'
+arbalest = '270'
+arcadian = '270'
+argus = '270'
+arian = '270'
+ark1ball = '90'
+arkangc = '90'
+arkangc2 = '90'
+arkanoid = '90'
+arkanoidj = '90'
+arkanoidja = '90'
+arkanoidjb = '90'
+arkanoidjbl = '90'
+arkanoidjbl2 = '90'
+arkanoidu = '90'
+arkanoiduo = '90'
+arkatayt = '90'
+arkatour = '90'
+arkbloc2 = '90'
+arkbloc3 = '90'
+arkblock = '90'
+arkgcbl = '90'
+arkgcbla = '90'
+arknoid2 = '270'
+arknoid2b = '270'
+arknoid2j = '270'
+arknoid2u = '270'
+arktayt2 = '90'
+armchmp2 = '270'
+armchmp2o = '270'
+armchmp2o2 = '270'
+armedf = '270'
+armedff = '270'
+armorcar = '90'
+armorcar2 = '90'
+ashura = '270'
+ashuraj = '270'
+ashurau = '270'
+asideral = '90'
+aso = '270'
+assault = '90'
+assaultj = '90'
+assaultp = '90'
+asterion = '90'
+astinvad = '270'
+astrians = '90'
+astroamb = '90'
+astrob = '270'
+astrob1 = '270'
+astrob2 = '270'
+astrob2a = '270'
+astrob2b = '270'
+astrobg = '270'
+astrof = '90'
+astrof2 = '90'
+astrof3 = '90'
+astroff = '90'
+astroff2 = '90'
+astrowar = '90'
+asuka = '270'
+asukaj = '270'
+asukaja = '270'
+asylum = '270'
+ataqandr = '90'
+atetrisc = '270'
+atetrisc2 = '270'
+atlantis = '90'
+atlantis2 = '90'
+atlantisb = '270'
+atombjt = '270'
+attckufo = '270'
+avefenix = '90'
+avefenixl = '90'
+avefenixrf = '90'
+avengers = '90'
+avengers2 = '90'
+azurian = '90'
+bagman = '270'
+bagmanf = '270'
+bagmanj = '270'
+bagmanm2 = '90'
+bagmanmc = '90'
+bagmans = '270'
+bagmans3 = '270'
+bagmans4 = '270'
+bagnard = '270'
+bagnarda = '270'
+bagnardi = '90'
+bagnardio = '90'
+bakubrkr = '90'
+ballbomb = '270'
+baluba = '90'
+bandido = '270'
+bandit = '90'
+baracuda = '90'
+barrier = '270'
+baryon = '270'
+baryona = '270'
+batman2 = '270'
+batrider = '270'
+batriderc = '270'
+batriderhk = '270'
+batriderj = '270'
+batriderja = '270'
+batriderk = '270'
+batridert = '270'
+batrideru = '270'
+batsugun = '270'
+batsuguna = '270'
+batsugunb = '270'
+batsugunsp = '270'
+battlane = '90'
+battlane2 = '90'
+battlane3 = '90'
+battles = '90'
+battles2 = '90'
+battlnts = '90'
+battlntsa = '90'
+battlntsj = '90'
+battroad = '90'
+bbakraid = '270'
+bbakraidc = '270'
+bbakraidj = '270'
+bbakraidja = '270'
+bcruzm12 = '90'
+beaminv = '270'
+beastf = '270'
+beastfp = '90'
+beezer = '90'
+beezer1 = '90'
+berenstn = '90'
+bermudat = '270'
+bermudata = '270'
+bermudatj = '270'
+bgaregga = '270'
+bgareggabl = '270'
+bgareggabla = '270'
+bgareggacn = '270'
+bgareggahk = '270'
+bgaregganv = '270'
+bgareggat = '270'
+bgareggat2 = '270'
+bgareggatw = '270'
+bigbang = '270'
+bigevglf = '270'
+bigevglfj = '270'
+bigkong = '270'
+bigprowr = '270'
+bikkuric = '90'
+billiard = '90'
+bioatack = '270'
+birdiy = '270'
+birdtry = '270'
+birdtrya = '270'
+bjtwin = '270'
+bjtwina = '270'
+bjtwinp = '270'
+bjtwinpa = '270'
+bking = '270'
+bking2 = '90'
+bking3 = '90'
+blackhol = '270'
+bladestl = '90'
+bladestle = '90'
+bladestll = '90'
+blastoff = '90'
+blazer = '90'
+blkbustr = '90'
+blkhole = '90'
+block = '270'
+block2 = '90'
+blockbl = '270'
+blockcar = '90'
+blockcarb = '90'
+blockch = '270'
+blockgal = '90'
+blockgalb = '90'
+blockj = '270'
+blockr1 = '270'
+blockr2 = '270'
+blokpong = '270'
+blswhstl = '90'
+blswhstla = '90'
+bluehawk = '270'
+bluehawkn = '270'
+blueprnt = '270'
+blueprntj = '270'
+bnj = '270'
+bnjm = '270'
+boggy84 = '90'
+boggy84b = '90'
+boggy84b2 = '90'
+bombbee = '90'
+bomber = '90'
+bombjack = '90'
+bombjack2 = '90'
+bombjackt = '90'
+bombsa = '270'
+bongo = '90'
+boobhack = '270'
+boomrang = '270'
+boomranga = '270'
+boonggab = '270'
+botanic = '90'
+botanic2 = '90'
+botanicf = '270'
+bowl3d = '90'
+bowler = '90'
+bowlrama = '270'
+brdrlinb = '270'
+brdrline = '270'
+brdrlinet = '270'
+brdrlins = '270'
+breakout = '90'
+brickzn = '90'
+brickzn11 = '90'
+brickznv4 = '90'
+brickznv5 = '90'
+brix = '90'
+bronx = '90'
+brubber = '270'
+brvblade = '270'
+brvbladea = '270'
+brvbladej = '270'
+brvbladeu = '270'
+btime = '270'
+btime2 = '270'
+btime3 = '270'
+btimem = '270'
+btlfield = '90'
+btlfieldb = '90'
+bucaner = '90'
+bucanera = '90'
+buggychl = '270'
+buggychlt = '270'
+bullfgtr = '90'
+bullfgtrs = '90'
+bullsdrt = '270'
+bullsdrtg = '90'
+buraiken = '90'
+buraikenb = '90'
+buzzard = '90'
+bwings = '90'
+bwingsa = '90'
+bwingso = '90'
+cadanglr = '270'
+cairblad = '270'
+calibr50 = '270'
+calipso = '90'
+cannballv = '270'
+cannonb = '90'
+cannonb2 = '90'
+cannonb3 = '90'
+cannonbp = '90'
+capbowl = '270'
+capbowl2 = '270'
+capbowl3 = '270'
+capbowl4 = '270'
+capitol = '90'
+caractn = '270'
+caractn2 = '270'
+carhntds = '270'
+carjmbre = '90'
+carnival = '270'
+carnivalb = '270'
+carnivalc = '270'
+carnivalca = '270'
+carnivalh = '270'
+carnivalha = '270'
+castfant = '270'
+catacomb = '90'
+catapult = '270'
+caterplr = '270'
+catnmous = '90'
+catnmousa = '90'
+cavelon = '90'
+cbdash = '270'
+cbnj = '270'
+cbtime = '270'
+cburnrub = '270'
+cburnrub2 = '270'
+cburnrubj = '270'
+cchasm = '270'
+cchasm1 = '270'
+cdiscon1 = '270'
+cencourt = '270'
+centipdb = '270'
+centipdb2 = '270'
+centipdd = '270'
+centiped = '270'
+centiped1 = '270'
+centiped2 = '270'
+centiped3 = '270'
+centipedj = '270'
+cexplore = '270'
+cfarm = '90'
+cfboy0a1 = '270'
+cfghtice = '270'
+cfishing = '270'
+cflyball = '270'
+cgraplop = '270'
+cgraplop2 = '270'
+cgraplopj = '270'
+chamburger = '270'
+champbwl = '270'
+chanbara = '270'
+checkman = '90'
+checkmanj = '90'
+cheekyms = '270'
+chinhero = '90'
+chinhero2 = '90'
+chinhero3 = '90'
+chinherot = '90'
+chkun = '90'
+chopper = '270'
+choppera = '270'
+chopperb = '270'
+chqflag = '90'
+chqflagj = '90'
+chwy = '270'
+circusc = '90'
+circusc2 = '90'
+circusc3 = '90'
+circusc4 = '90'
+circuscc = '90'
+circusce = '90'
+cityatta = '90'
+citybomb = '270'
+citybombj = '270'
+ckong = '270'
+ckongalc = '270'
+ckongcv = '90'
+ckongdks = '270'
+ckongg = '90'
+ckonggx = '90'
+ckongis = '90'
+ckongmc = '90'
+ckongmc2 = '90'
+ckongo = '270'
+ckongpt2 = '270'
+ckongpt2a = '270'
+ckongpt2b = '270'
+ckongpt2b2 = '270'
+ckongpt2j = '270'
+ckongpt2jeu = '270'
+ckongpt2ss = '270'
+ckongs = '90'
+clapapa = '270'
+clapapa2 = '270'
+clbowl = '270'
+clocknch = '270'
+clocknchj = '270'
+clubpacm = '90'
+clubpacma = '90'
+cmanhat = '270'
+cmissnx = '270'
+cmmb103 = '270'
+cmmb162 = '270'
+cnightst = '270'
+cnightst2 = '270'
+cntsteer = '270'
+cocoloco = '90'
+cocolocoa = '90'
+cocolocob = '90'
+colony7 = '270'
+colony7a = '270'
+combh = '270'
+commando = '270'
+commandob = '270'
+commandob2 = '270'
+commandob3 = '270'
+commandoj = '270'
+commandou = '270'
+commandou2 = '270'
+commsega = '90'
+complexx = '270'
+condor = '90'
+condorn = '90'
+congo = '90'
+congoa = '90'
+conquer = '90'
+contra = '90'
+contra1 = '90'
+contrab = '90'
+contrabj = '90'
+contrabj1 = '90'
+contrae = '90'
+contraj = '90'
+contraj1 = '90'
+cookrace = '270'
+coozumou = '270'
+cosmica = '270'
+cosmica1 = '270'
+cosmica22 = '270'
+cosmica23 = '270'
+cosmica2a = '270'
+cosmicg = '270'
+cosmicgi = '270'
+cosmicin = '270'
+cosmicm2 = '270'
+cosmicmo = '270'
+cosmo = '90'
+cosmogng = '90'
+cosmogngj = '90'
+cosmos = '90'
+cottong = '90'
+cowtipp = '270'
+cowtippa = '270'
+cppicf = '270'
+cppicf2 = '270'
+cprobowl = '270'
+cprogolf = '270'
+cprogolf18 = '270'
+cprogolfj = '270'
+cpsoccer = '270'
+cpsoccerj = '270'
+cptennis = '270'
+cptennisj = '270'
+crashrd = '270'
+crazyblk = '90'
+crazycop = '90'
+crbaloon = '90'
+crbaloon2 = '90'
+crockman = '90'
+crockmnf = '90'
+crshrace = '270'
+crshrace2 = '270'
+crush = '90'
+crush2 = '90'
+crush3 = '90'
+crush4 = '90'
+crush5 = '90'
+crushbl = '90'
+crushbl2 = '90'
+crushbl3 = '90'
+crushrlf = '90'
+crushs = '90'
+crzrally = '270'
+crzrallya = '270'
+crzrallyg = '270'
+crzrallyrf = '270'
+cscrtry = '270'
+cscrtry2 = '270'
+csdtenis = '270'
+cshooter = '270'
+cshootert = '270'
+cskater = '270'
+csuperas = '270'
+csweetht = '270'
+ctainv = '270'
+cterrani = '270'
+ctisland = '270'
+ctisland2 = '270'
+ctisland3 = '270'
+ctornado = '270'
+ctrpllrp = '90'
+curvebal = '270'
+cutieq = '90'
+cybattlr = '90'
+cyclshtg = '90'
+cyvern = '90'
+cyvernj = '90'
+czeroize = '270'
+dai3wksi = '270'
+daikaiju = '270'
+daioh = '270'
+daioha = '270'
+daiohc = '270'
+daiohp = '270'
+daiohp2 = '270'
+daisenpu = '270'
+dambustr = '90'
+dambustra = '90'
+dambustruk = '90'
+dangar = '270'
+dangara = '270'
+dangarb = '270'
+dangarbt = '270'
+dangarj = '270'
+dangseed = '90'
+darkmist = '270'
+darkwar = '90'
+darthvdr = '270'
+darwin = '270'
+dazzler = '90'
+dblewing = '90'
+dblewinga = '90'
+dblewingb = '90'
+ddaydoo = '270'
+ddayjlc = '90'
+ddayjlca = '90'
+ddonpach = '270'
+ddonpacha = '270'
+ddonpachj = '270'
+ddp2 = '270'
+ddp2100 = '270'
+ddp2100c = '270'
+ddp2100hk = '270'
+ddp2100j = '270'
+ddp2100k = '270'
+ddp2100t = '270'
+ddp2101 = '270'
+ddp2101c = '270'
+ddp2101hk = '270'
+ddp2101j = '270'
+ddp2101k = '270'
+ddp2101t = '270'
+ddp2c = '270'
+ddp2hk = '270'
+ddp2j = '270'
+ddp2k = '270'
+ddp2t = '270'
+ddp3 = '270'
+ddpdfk = '270'
+ddpdfk10 = '270'
+ddpdoj = '270'
+ddpdoja = '270'
+ddpdojb = '270'
+ddpdojblk = '270'
+ddpdojblka = '270'
+ddpdojblkbl = '270'
+ddpdojp = '270'
+ddpdojt = '270'
+decomult = '270'
+demoneye = '270'
+desertbr = '270'
+desertbrd = '270'
+desertbrj = '270'
+desertbrjd = '270'
+desertwr = '270'
+desterth = '270'
+destryer = '90'
+destryera = '90'
+detatwin = '90'
+devilfsg = '270'
+devilfsh = '90'
+devstors = '90'
+devstors2 = '90'
+devstorsv = '90'
+devstorsx = '90'
+devzone = '270'
+devzone2 = '270'
+dfeveron = '270'
+dfkbl = '270'
+digdug = '90'
+digdug1 = '90'
+digdug2 = '90'
+digdug2o = '90'
+digdugat = '90'
+digdugat1 = '90'
+digger = '270'
+diggerc = '90'
+digsid = '90'
+dimahoo = '270'
+dimahoou = '270'
+dimahoud = '270'
+dingo = '90'
+dingoe = '90'
+dirtfoxj = '90'
+disco = '270'
+discoboy = '270'
+discoboyp = '270'
+discof = '270'
+divebomb = '270'
+dking = '270'
+dkingjr = '270'
+dkmb = '270'
+dkong = '270'
+dkong3 = '270'
+dkong3abl = '90'
+dkong3b = '270'
+dkong3hs = '270'
+dkong3j = '270'
+dkongddk = '270'
+dkongf = '270'
+dkonghrd = '270'
+dkonghs = '270'
+dkongike = '270'
+dkongj = '270'
+dkongjnrj = '270'
+dkongjo = '270'
+dkongjo1 = '270'
+dkongjr = '270'
+dkongjr2 = '270'
+dkongjrb = '270'
+dkongjrc = '270'
+dkongjre = '270'
+dkongjrhs = '270'
+dkongjrj = '270'
+dkongjrm = '90'
+dkongjrmc = '90'
+dkongjrpb = '270'
+dkongo = '270'
+dkongpe = '270'
+dkongx = '270'
+dkongx11 = '270'
+docastle = '270'
+docastle2 = '270'
+docastleo = '270'
+dockman = '90'
+dockmanb = '90'
+dockmanc = '90'
+dogfight = '270'
+dogfightp = '270'
+dogosokb = '270'
+dogosoke = '270'
+dogyuun = '270'
+dogyuuna = '270'
+dogyuunt = '270'
+dommy = '270'
+donpachi = '270'
+donpachihk = '270'
+donpachij = '270'
+donpachijs = '270'
+donpachikr = '270'
+dorachan = '270'
+dorodon = '270'
+dorodon2 = '270'
+douni = '270'
+downtown = '270'
+downtown2 = '270'
+downtownj = '270'
+downtownp = '270'
+draco = '90'
+dragnblz = '270'
+drakton = '270'
+dremshpr = '270'
+driftout = '270'
+driftoutct = '270'
+driftoutj = '270'
+driveout = '270'
+drivfrcb = '90'
+drivfrcg = '90'
+drivfrcp = '90'
+drivfrcsg = '90'
+drivfrcsga = '90'
+drivfrct = '90'
+drktnjr = '270'
+drmicro = '270'
+drtoppel = '90'
+drtoppelj = '90'
+drtoppelu = '90'
+dsaber = '90'
+dsabera = '90'
+dsaberj = '90'
+dspirit = '90'
+dspirit1 = '90'
+dspirit2 = '90'
+dualaslt = '270'
+dyger = '270'
+dygera = '270'
+dynamski = '90'
+dzigzag = '90'
+eagle = '90'
+eagle2 = '90'
+eagle3 = '90'
+earthjkr = '270'
+earthjkra = '270'
+earthjkrb = '270'
+earthjkrp = '270'
+eeekk = '270'
+eeekkp = '90'
+efdt = '90'
+eggor = '90'
+eggs = '270'
+eightfrc = '90'
+elecyoyo = '270'
+elecyoyo2 = '270'
+enigma2 = '270'
+enigma2a = '270'
+enigma2b = '270'
+equites = '90'
+equitess = '90'
+escmars = '270'
+espgal = '270'
+espgal2 = '270'
+espgal2a = '270'
+espgalbl = '270'
+esprade = '270'
+espradej = '270'
+espradejo = '270'
+evilston = '270'
+excthour = '270'
+exctscc2 = '270'
+exctsccr = '270'
+exctsccra = '270'
+exctsccrb = '270'
+exctsccrj = '270'
+exctsccrjo = '270'
+exctsccru = '270'
+exedexes = '270'
+exerion = '90'
+exerionb = '90'
+exerionb2 = '90'
+exerionba = '90'
+exeriont = '90'
+exerizer = '90'
+exerizerb = '90'
+exodus = '90'
+explbrkr = '90'
+explbrkrk = '90'
+explorer = '90'
+extrmatn = '270'
+extrmatnj = '270'
+extrmatnu = '270'
+extrmatnur = '270'
+eyes = '90'
+eyes2 = '90'
+eyesb = '90'
+eyeszac = '90'
+eyeszacb = '90'
+f1gp = '90'
+f1gp2 = '90'
+f1gpa = '90'
+f1gpb = '90'
+fa = '90'
+falcon = '90'
+falconz = '90'
+fantasia = '90'
+fantasiaa = '90'
+fantasiab = '90'
+fantasian = '90'
+fantastc = '90'
+fantasyg = '90'
+fantasyg2 = '90'
+fantasyj = '90'
+fantasyu = '90'
+fantazia = '90'
+fantsy95 = '90'
+fastdraw = '90'
+fastdraw_130 = '90'
+fastfred = '90'
+fastlane = '90'
+fcombat = '90'
+fenix = '90'
+fenixn = '90'
+feversos = '270'
+fghtatck = '90'
+fgoal = '90'
+fgoala = '90'
+fhawk = '270'
+fhawkj = '270'
+fi6845 = '270'
+fi8275 = '270'
+fightrol = '270'
+finalizr = '90'
+finalizra = '90'
+finalizrb = '90'
+firebarr = '270'
+firebatl = '90'
+fireshrk = '270'
+fireshrka = '270'
+fireshrkd = '270'
+fireshrkdh = '270'
+firetrap = '90'
+firetrapa = '90'
+firetrapbl = '90'
+firetrapj = '90'
+firetrk = '270'
+fitter = '90'
+fitterbl = '90'
+fiveside = '90'
+fixeight = '270'
+fixeighta = '270'
+fixeightat = '270'
+fixeightbl = '270'
+fixeighth = '270'
+fixeightht = '270'
+fixeightj = '270'
+fixeightjt = '270'
+fixeightk = '270'
+fixeightkt = '270'
+fixeightt = '270'
+fixeighttw = '270'
+fixeighttwt = '270'
+fixeightu = '270'
+fixeightut = '270'
+flipjack = '270'
+flkatck = '90'
+flkatcka = '90'
+floritas = '90'
+floritasm = '90'
+flyboy = '90'
+flyboyb = '90'
+flytiger = '270'
+flytigera = '270'
+fnkyfish = '90'
+fnshark = '270'
+fourplay = '90'
+freekick = '270'
+freekicka = '270'
+freekickb1 = '270'
+freekickb2 = '270'
+freekickb3 = '270'
+freeze = '90'
+frogf = '90'
+frogg = '90'
+frogger = '90'
+froggeram = '90'
+froggermc = '90'
+froggers = '90'
+froggers1 = '90'
+froggers2 = '90'
+froggers3 = '90'
+froggerv = '90'
+froggervd = '90'
+froggrs = '90'
+frontlin = '270'
+frontlina = '270'
+fshark = '270'
+fsharkb = '270'
+fsharkbt = '270'
+fspiderb = '90'
+fstarfrc = '90'
+fstarfrcj = '90'
+fstarfrcw = '90'
+funkybee = '90'
+funkybeeb = '90'
+funnymou = '90'
+futari10 = '270'
+futari15 = '270'
+futari15a = '270'
+futaribl = '270'
+futariblj = '270'
+futflash = '270'
+futspy = '90'
+fx = '90'
+gaiapols = '90'
+gaiapolsj = '90'
+gaiapolsu = '90'
+galactic = '270'
+galaga = '90'
+galaga3 = '90'
+galaga3a = '90'
+galaga3b = '90'
+galaga3c = '90'
+galaga3m = '90'
+galaga88 = '270'
+galaga88a = '90'
+galaga88j = '90'
+galagamf = '90'
+galagamk = '90'
+galagamw = '90'
+galagao = '90'
+galaktron = '90'
+galap1 = '90'
+galap4 = '90'
+galapx = '90'
+galaxbsf = '90'
+galaxbsf2 = '90'
+galaxcirsa = '90'
+galaxia = '90'
+galaxiaa = '90'
+galaxiab = '90'
+galaxiac = '90'
+galaxian = '90'
+galaxiana = '90'
+galaxianbl = '90'
+galaxianbl2 = '90'
+galaxianbl3 = '90'
+galaxianem = '90'
+galaxiani = '90'
+galaxianm = '90'
+galaxianmo = '90'
+galaxianrp = '90'
+galaxiant = '90'
+galaxrcgg = '90'
+galaxrf = '90'
+galaxrfgg = '90'
+galaxygn = '90'
+galaxygnh = '90'
+galaxyx = '90'
+galemp = '90'
+galgame = '270'
+galivan = '270'
+galivan2 = '270'
+galivan3 = '270'
+galkamika = '90'
+gallag = '90'
+galmedes = '270'
+galmonst = '270'
+galpani2 = '90'
+galpani2e = '90'
+galpani2e2 = '90'
+galpani2g = '90'
+galpani2gs = '90'
+galpani2i = '90'
+galpani2i2 = '90'
+galpani2j = '90'
+galpani2t = '90'
+galpani3 = '90'
+galpani3hk = '90'
+galpani3j = '90'
+galpani3k = '90'
+galpanic = '90'
+galpanica = '90'
+galpanicb = '90'
+galpanicms = '90'
+galsnew = '90'
+galsnewj = '90'
+galsnewk = '90'
+galsnewt = '90'
+galsnewu = '90'
+galspnbl = '90'
+galturbo = '90'
+galxwars = '270'
+galxwars2 = '270'
+galxwarst = '270'
+galxwarst2 = '270'
+gametngk = '270'
+gangonta = '90'
+gaplus = '90'
+gaplusa = '90'
+gaplusd = '90'
+gaplust = '90'
+gardia = '270'
+gardiab = '270'
+gardiaj = '270'
+garuka = '90'
+gatsbee = '90'
+gbusters = '90'
+gbustersa = '90'
+gcpinbal = '270'
+geebee = '90'
+geebeea = '90'
+geebeeb = '90'
+geebeeg = '90'
+gekiridn = '270'
+gekiridnj = '270'
+gekisou = '90'
+gemcrush = '270'
+gemini = '90'
+geminib = '90'
+geminij = '90'
+getaway = '270'
+gground = '270'
+ggroundj = '270'
+ghostmun = '90'
+ghox = '270'
+ghoxj = '270'
+ghoxjo = '270'
+gigas = '270'
+gigasb = '270'
+gigasm2 = '270'
+gigasm2b = '270'
+gmahou = '270'
+gmgalax = '90'
+gnbarich = '270'
+goindol = '90'
+goindolj = '90'
+goindolu = '90'
+goldbug = '90'
+gondo = '270'
+gondou = '270'
+gorf = '270'
+gorfpgm1 = '270'
+gorfpgm1g = '270'
+gorkans = '90'
+gotya = '270'
+gp2quiz = '90'
+gp2se = '90'
+grasspin = '270'
+grchamp = '270'
+grchampa = '270'
+grchampb = '270'
+grdnstrmg = '270'
+grdnstrmj = '270'
+grdnstrmk = '270'
+grdnstrmv = '270'
+greenber = '270'
+grescue = '270'
+griffon = '90'
+griffono = '90'
+grindstm = '270'
+grindstma = '270'
+grobda = '90'
+grobda2 = '90'
+grobda3 = '90'
+gryzor = '90'
+gryzor1 = '90'
+gseeker = '90'
+gseekerj = '90'
+gseekeru = '90'
+gstream = '270'
+gteikoku = '90'
+gteikokub = '270'
+gteikokub2 = '90'
+gteikokub3 = '90'
+gulfstrm = '270'
+gulfstrma = '270'
+gulfstrmb = '270'
+gulfstrmk = '270'
+gulfstrmm = '270'
+gulfwar2 = '270'
+gulfwar2a = '270'
+gunball = '270'
+gunbird = '270'
+gunbird2 = '270'
+gunbird2a = '270'
+gunbirdj = '270'
+gunbirdk = '270'
+gundealr = '270'
+gundealra = '270'
+gundealrbl = '270'
+gundealrt = '270'
+gundhara = '270'
+gundharac = '270'
+gunfront = '270'
+gunfrontj = '270'
+gunlock = '90'
+gunlocko = '270'
+gunnail = '270'
+gunnailb = '270'
+gunnailp = '270'
+gunsmoke = '270'
+gunsmokeb = '270'
+gunsmokeg = '270'
+gunsmokej = '270'
+gunsmokeu = '270'
+gunsmokeua = '270'
+gunsmokeub = '270'
+gutangtn = '90'
+guttangt = '90'
+guttangts3 = '90'
+guwange = '270'
+guwanges = '270'
+guzzler = '90'
+guzzlers = '90'
+gwar = '270'
+gwara = '270'
+gwarab = '270'
+gwarb = '270'
+gwarj = '270'
+gyrodine = '90'
+gyrodinet = '90'
+gyruss = '90'
+gyrussb = '90'
+gyrussce = '90'
+hal21 = '270'
+hal21j = '270'
+halleysc = '90'
+halleysc87 = '90'
+halleyscj = '90'
+halleyscja = '90'
+halleyscjp = '90'
+hamaway = '90'
+hangly = '90'
+hangly2 = '90'
+hangly3 = '90'
+harem = '90'
+hbarrel = '270'
+hbarrelu = '270'
+headoni = '270'
+headonn = '270'
+heartatk = '90'
+heiankyo = '270'
+helifire = '270'
+helifirea = '270'
+herbiedk = '270'
+hero = '90'
+herodk = '270'
+herodku = '270'
+hexpool = '90'
+hexpoola = '90'
+highsplt = '270'
+highsplta = '270'
+highspltb = '270'
+hishouza = '270'
+hishouzab = '270'
+hncholms = '90'
+hoccer = '90'
+hoccer2 = '90'
+homo = '90'
+hopprobo = '90'
+horekid = '270'
+horekidb = '270'
+horshoes = '270'
+hotdogst = '90'
+hotnight = '90'
+hotpinbl = '90'
+hotshock = '90'
+hotshockb = '90'
+hotsmash = '90'
+hstennis = '90'
+hstennis10 = '90'
+hunchbak = '90'
+hunchbaka = '90'
+hunchbgb = '90'
+hunchbkd = '270'
+hunchbkg = '90'
+hunchbks = '90'
+hunchbks2 = '90'
+huncholy = '90'
+hustler = '90'
+hustlerb = '90'
+hustlerb2 = '90'
+hustlerb3 = '90'
+hustlerb4 = '90'
+hustlerb5 = '90'
+hustlerb6 = '90'
+hustlerd = '90'
+hwrace = '270'
+ibara = '270'
+ibarablk = '270'
+ibarablka = '270'
+igmo = '270'
+ikari = '270'
+ikaria = '270'
+ikaria2 = '270'
+ikarijp = '270'
+ikarijpb = '270'
+ikarinc = '270'
+ikariram = '270'
+ikaruga = '270'
+illvelo = '270'
+imago = '90'
+imagoa = '90'
+imgfight = '270'
+imgfightb = '270'
+imgfightj = '270'
+imolagp = '90'
+imolagpo = '90'
+indianbt = '270'
+indianbtbr = '270'
+indy800 = '90'
+intrepid = '90'
+intrepid2 = '90'
+intrepidb = '90'
+intrepidb2 = '90'
+intrepidb3 = '90'
+intruder = '270'
+invad2ct = '90'
+invaddlx = '270'
+invader4 = '270'
+invaderl = '270'
+invadernc = '270'
+invaders = '270'
+invadersem = '270'
+invadpt2 = '270'
+invadpt2a = '270'
+invadpt2br = '270'
+invadrmr = '270'
+invasion = '270'
+invasiona = '270'
+invasiona2 = '270'
+invasionb = '270'
+invasionrz = '270'
+invasionrza = '270'
+invcarht = '270'
+invds = '270'
+invho2 = '270'
+invho2a = '270'
+invinco = '270'
+invmulti = '270'
+invmultim1a = '270'
+invmultim2a = '270'
+invmultim2c = '270'
+invmultim3a = '270'
+invmultip = '270'
+invmultis1a = '270'
+invmultis2a = '270'
+invmultis3a = '270'
+invmultit3d = '270'
+invqix = '270'
+invrvnge = '270'
+invrvngea = '270'
+invrvngeb = '270'
+invrvngedu = '270'
+invrvngegw = '270'
+ipminvad = '270'
+ipminvad1 = '270'
+irion = '90'
+ixion = '270'
+jack = '90'
+jack2 = '90'
+jack3 = '90'
+jackal = '90'
+jackalbl = '90'
+jackalj = '90'
+jackalr = '90'
+jackler = '90'
+jackrabt = '90'
+jackrabt2 = '90'
+jackrabts = '90'
+jcross = '270'
+jcrossa = '270'
+jin = '90'
+jjack = '270'
+joinem = '90'
+jollyjgr = '90'
+jongbou = '90'
+josvolly = '90'
+journey = '90'
+joust2 = '270'
+joust2r1 = '270'
+joyfulr = '270'
+joyman = '90'
+jrking = '270'
+jrpacman = '90'
+jrpacmanf = '90'
+jrpacmbl = '90'
+jspecter = '270'
+jspecter2 = '270'
+jumpbug = '90'
+jumpbugb = '90'
+jumpcoas = '90'
+jumpcoasa = '90'
+jumpcoast = '90'
+jumpkun = '90'
+jumpshot = '90'
+jumpshotp = '90'
+jungler = '90'
+junglero = '90'
+junglers = '90'
+jungsub = '90'
+junofrst = '90'
+junofrstg = '90'
+kageki = '90'
+kagekih = '90'
+kagekij = '90'
+kagekiu = '90'
+kaitei = '90'
+kaitein = '90'
+kamakazi3 = '90'
+kamikaze = '270'
+kamikazesp = '90'
+kamikazp = '90'
+kamikcab = '270'
+kangaroo = '90'
+kangarooa = '90'
+kangaroob = '90'
+kangarool = '90'
+kaos = '270'
+karateda = '90'
+karatedo = '90'
+karatevs = '90'
+karous = '270'
+kbh = '270'
+kbm = '270'
+kbm2nd = '270'
+kbm3rd = '270'
+kchamp = '90'
+kchamptec = '90'
+kchampvs = '90'
+kchampvs2 = '90'
+kchampvs3 = '90'
+kchampvs4 = '90'
+ket = '270'
+ket1 = '270'
+keta = '270'
+ketarr = '270'
+ketarr10 = '270'
+ketarr15 = '270'
+ketarr151 = '270'
+ketarrf = '270'
+ketarrs15 = '270'
+ketarrs151 = '270'
+ketb = '270'
+ketbl = '270'
+kick = '90'
+kickc = '90'
+kicker = '90'
+kickman = '90'
+kikikai = '90'
+kingball = '90'
+kingballj = '90'
+kingdmgp = '270'
+kingofb = '90'
+kingofbj = '90'
+knightb = '90'
+knockout = '90'
+knockoutb = '90'
+knockoutc = '90'
+komemokos = '270'
+konam80a = '90'
+konam80j = '90'
+konam80k = '90'
+konam80s = '90'
+konam80u = '90'
+kong = '90'
+korosuke = '90'
+kosmokil = '270'
+krull = '270'
+krzybowl = '270'
+ktiger = '270'
+ktiger2 = '270'
+kuhga = '270'
+kyros = '90'
+kyrosj = '90'
+labyrunr = '90'
+labyrunrk = '90'
+ladybug = '270'
+ladybugb = '270'
+ladybugb2 = '270'
+ladybugg = '270'
+ladyfrog = '90'
+ladykill = '90'
+ladymstr = '90'
+ladymstr2 = '90'
+lagirl = '270'
+lagunar = '90'
+laser = '270'
+laserbas = '270'
+laserbasa = '270'
+lasso = '90'
+lastday = '270'
+lastdaya = '270'
+lastduel = '270'
+lastduelb = '270'
+lastduelj = '270'
+lastduelo = '270'
+lastmisn = '270'
+lastmisnj = '270'
+lastmisnu5 = '270'
+lastmisnu6 = '270'
+leds2011 = '270'
+leds2011p = '270'
+leds2011u = '270'
+ledstorm = '270'
+legion = '270'
+legionj = '270'
+legionjb = '270'
+legionjb2 = '270'
+legofair = '270'
+lethalth = '270'
+levers = '90'
+lgtnfght = '90'
+lgtnfghta = '90'
+lgtnfghtu = '90'
+liberate = '270'
+liberateb = '270'
+lizwiz = '90'
+lnc = '270'
+locoboot = '90'
+locomotn = '90'
+logger = '90'
+loggerr2 = '90'
+looping = '90'
+loopingv = '90'
+loopingva = '90'
+losttomb = '90'
+losttombh = '90'
+loverboy = '90'
+lrescue = '270'
+lrescuem = '270'
+lrescuem2 = '270'
+lsasquad = '270'
+lupin3 = '270'
+lupin3a = '270'
+lwings = '90'
+lwings2 = '90'
+lwingsb = '90'
+lwingsj = '90'
+lwingsja = '90'
+m660 = '90'
+m660b = '90'
+m660j = '90'
+mach9 = '270'
+machomou = '90'
+macross = '270'
+macrossp = '270'
+madalien = '270'
+madaliena = '270'
+madalienb = '270'
+madgear = '270'
+madgearj = '270'
+madshark = '270'
+mag_day = '90'
+mag_time = '90'
+mag_war = '90'
+mag_wara = '90'
+magspot = '270'
+magspot2 = '270'
+maguila = '270'
+magworm = '270'
+magworma = '270'
+mahoudai = '270'
+majest12j = '270'
+majest12u = '270'
+maketrax = '270'
+maketrxb = '270'
+makyosen = '270'
+mamonoro = '270'
+mandinga = '90'
+mandingac = '90'
+mandingaeg = '90'
+mandingarf = '90'
+maniach = '270'
+maniach2 = '270'
+manybloc = '270'
+mappy = '90'
+mappyj = '90'
+marinedt = '270'
+mariner = '90'
+mars = '90'
+marsarf = '90'
+marvins = '270'
+masterw = '270'
+masterwj = '270'
+masterwu = '270'
+matmania = '270'
+mazeinv = '270'
+mazinger = '90'
+mazingerj = '90'
+mbrush = '90'
+megadon = '270'
+megaforc = '90'
+megaforcu = '90'
+megazone = '90'
+megazonea = '90'
+megazoneb = '90'
+megazoneh = '90'
+megazonei = '90'
+megazonej = '90'
+mercenario = '270'
+mercs = '270'
+mercsj = '270'
+mercsu = '270'
+mercsur1 = '270'
+merlinmm = '90'
+metafox = '270'
+meteor = '270'
+meteora = '90'
+metlfrzr = '270'
+metlhawk = '90'
+metlhawkj = '90'
+mgolf = '270'
+mightguy = '270'
+mikie = '270'
+mikiehs = '270'
+mikiej = '270'
+mikiek = '270'
+millipdd = '270'
+milliped = '270'
+millpac = '270'
+mimonkey = '90'
+mimonkeyug = '90'
+mimonsco = '90'
+mimonscr = '90'
+mimonscra = '90'
+minefld = '90'
+minefldfe = '90'
+mirax = '90'
+miraxa = '90'
+misncrft = '90'
+misncrfta = '90'
+mjleague = '270'
+mlander = '270'
+mltiwars = '90'
+mmagic = '270'
+mmonkey = '270'
+mmonkeyj = '270'
+mmpork = '270'
+mnchmobl = '270'
+mocapb = '90'
+mocapbj = '90'
+mocapglf = '90'
+moegonta = '90'
+mofflott = '270'
+moguchan = '270'
+monkeyd = '270'
+monsterb = '270'
+monsterb2 = '270'
+montecar = '270'
+monymony = '90'
+monymony2 = '90'
+monzagp = '270'
+monzagpb = '270'
+moonal2 = '90'
+moonal2b = '90'
+moonaln = '90'
+moonbase = '270'
+moonbasea = '270'
+mooncmw = '90'
+mooncptc = '90'
+mooncreg = '90'
+mooncrgx = '270'
+mooncrs2 = '90'
+mooncrs3 = '90'
+mooncrs4 = '90'
+mooncrs5 = '90'
+mooncrsb = '90'
+mooncrsl = '90'
+mooncrst = '90'
+mooncrstg = '90'
+mooncrsto = '90'
+mooncrstso = '90'
+mooncrstu = '90'
+mooncrstuk = '90'
+mooncrstuku = '90'
+mooncrstuu = '90'
+moonqsr = '90'
+moonwar = '90'
+moonwara = '90'
+motorace = '270'
+motos = '90'
+mototour = '270'
+mouncrst = '90'
+mouser = '90'
+mouserc = '90'
+mplanets = '270'
+mplanetsuk = '270'
+mrdo = '270'
+mrdobl = '270'
+mrdofabr = '270'
+mrdofix = '270'
+mrdot = '270'
+mrdoy = '270'
+mrdu = '270'
+mrflea = '270'
+mrjong = '90'
+mrkougar = '90'
+mrkougar2 = '90'
+mrkougb = '90'
+mrkougb2 = '90'
+mrlo = '270'
+mrsdyna = '270'
+mrtnt = '90'
+mrviking = '270'
+mrvikingj = '270'
+mschamp = '90'
+mschamps = '90'
+msheartb = '90'
+msisaac = '270'
+mspacii = '90'
+mspacii2 = '90'
+mspacmab = '90'
+mspacmab2 = '90'
+mspacmab3 = '90'
+mspacmab4 = '90'
+mspacman = '90'
+mspacmanbcc = '90'
+mspacmanbco = '90'
+mspacmanbg = '90'
+mspacmanbg2 = '90'
+mspacmanbgd = '90'
+mspacmanbhe = '90'
+mspacmanbi = '90'
+mspacmanblt = '90'
+mspacmanblt2 = '90'
+mspacmancr = '90'
+mspacmat = '90'
+mspacmbe = '90'
+mspacmbmc = '90'
+mspacmbn = '90'
+mspacmnf = '90'
+mspacpls = '90'
+mspactwin = '90'
+mspuzzle = '90'
+mspuzzlea = '90'
+mspuzzleb = '90'
+multiped = '270'
+mushisam = '270'
+mushisama = '270'
+mushisamb = '270'
+musicbal = '270'
+mustache = '90'
+mustachei = '90'
+mx5000 = '90'
+myqbert = '270'
+mystston = '270'
+myststono = '270'
+myststonoi = '270'
+naughtyb = '90'
+naughtyba = '90'
+naughtybb = '90'
+naughtybc = '90'
+navarone = '90'
+ncentury = '90'
+ncv1 = '90'
+ncv1j = '90'
+ncv1j2 = '90'
+ncv2 = '90'
+ncv2j = '90'
+nebulbee = '90'
+nebulray = '90'
+nebulrayj = '90'
+neobattl = '270'
+netwars = '90'
+newfant = '90'
+newfanta = '90'
+newpuc2 = '90'
+newpuc2b = '90'
+newpuckx = '90'
+newsin7 = '90'
+newsin7a = '90'
+nextfase = '90'
+nibbler = '90'
+nibbler6 = '90'
+nibbler7 = '90'
+nibbler8 = '90'
+nibblera = '90'
+nibblero = '90'
+nibblerp = '90'
+nightmare = '90'
+ninjemak = '270'
+nitrobal = '270'
+nitrobala = '270'
+nitrobalb = '270'
+nmouse = '90'
+nmouseb = '90'
+noahsark = '90'
+nob = '270'
+nobb = '270'
+nomnlnd = '270'
+nomnlndg = '270'
+nost = '270'
+nostj = '270'
+nostk = '270'
+nsub = '270'
+numcrash = '90'
+nunchaku = '90'
+nyny = '270'
+nynyg = '270'
+offensiv = '90'
+ohpaipee = '270'
+oigas = '270'
+olibochu = '270'
+olibug = '90'
+olmandingc = '90'
+olmandingo = '90'
+omega = '270'
+omegaa = '270'
+omegab = '270'
+omegaf = '270'
+omegafs = '270'
+omni = '90'
+ooparts = '270'
+orbite = '270'
+orbitron = '270'
+outline = '90'
+outzone = '270'
+outzonea = '270'
+outzoneb = '270'
+outzonec = '270'
+outzonecv = '270'
+outzoneh = '270'
+overdriv = '90'
+overdriva = '90'
+overdrivb = '90'
+ozmawars = '270'
+ozmawars2 = '270'
+ozmawarsmr = '270'
+ozon1 = '90'
+p911 = '90'
+p9112 = '90'
+p911ac = '90'
+p911ea = '90'
+p911ed = '90'
+p911j = '90'
+p911k = '90'
+p911kc = '90'
+p911ud = '90'
+pac90 = '90'
+pacgal = '90'
+pacgal2 = '90'
+pacheart = '90'
+pachifev = '270'
+packbang = '90'
+pacman = '90'
+pacmanbl = '270'
+pacmanbla = '270'
+pacmanblb = '90'
+pacmanblc = '270'
+pacmanblci = '270'
+pacmanblv = '270'
+pacmanf = '90'
+pacmanfm = '90'
+pacmania = '270'
+pacmaniaj = '90'
+pacmaniao = '270'
+pacmanjpm = '90'
+pacmanpe = '90'
+pacmanso = '90'
+pacmansp = '90'
+pacmanug = '90'
+pacmanvg = '90'
+pacmod = '90'
+pacnchmp = '90'
+pacnpal = '90'
+pacnpal2 = '90'
+pacominv = '270'
+pacominva = '270'
+pacplus = '90'
+pacuman = '90'
+paddle2 = '90'
+paddlema = '90'
+painter = '90'
+paintrlr = '90'
+pairlove = '270'
+pajaroes = '90'
+pandoras = '90'
+panic = '270'
+panic2 = '270'
+panic3 = '270'
+panicger = '270'
+panich = '270'
+panicr = '270'
+panicrg = '270'
+panther = '270'
+paprazzi = '270'
+para2dx = '90'
+paradise = '90'
+paradisea = '90'
+paradisee = '90'
+paradlx = '90'
+passht4b = '270'
+passsht = '270'
+passsht16a = '270'
+passshta = '270'
+passshtad = '270'
+passshtb = '270'
+passshtd = '270'
+passshtj = '270'
+passshtjd = '270'
+pbaction = '90'
+pbaction2 = '90'
+pbaction3 = '90'
+pbaction4 = '90'
+pbactiont = '90'
+pballoon = '90'
+pballoonr = '90'
+peggle = '90'
+pegglet = '90'
+pengo = '90'
+pengo2 = '90'
+pengo2u = '90'
+pengo3u = '90'
+pengo4 = '90'
+pengo5 = '90'
+pengo6 = '90'
+pengob = '90'
+pengojpm = '90'
+pengomc1 = '90'
+pengomc2 = '90'
+pengopac = '90'
+penta = '90'
+percuss = '270'
+perestro = '90'
+perestrof = '90'
+perfrman = '270'
+perfrmanu = '270'
+phelios = '90'
+pheliosj = '90'
+phoenix = '90'
+phoenix2 = '90'
+phoenix3 = '90'
+phoenixa = '90'
+phoenixass = '90'
+phoenixb = '90'
+phoenixc = '90'
+phoenixc2 = '90'
+phoenixc3 = '90'
+phoenixc4 = '90'
+phoenixdal = '90'
+phoenixgu = '90'
+phoenixha = '90'
+phoenixi = '90'
+phoenixj = '90'
+phoenixs = '90'
+phoenixt = '90'
+phoenxp2 = '270'
+phozon = '90'
+phozons = '90'
+pickin = '270'
+pignewt = '270'
+pignewta = '270'
+pinbo = '90'
+pinboa = '90'
+pinbos = '90'
+pinguinos = '90'
+pinkswts = '270'
+pinkswtsa = '270'
+pinkswtsb = '270'
+pinkswtssc = '270'
+pinkswtsx = '270'
+piranha = '90'
+piranhah = '90'
+piranhao = '90'
+piratesh = '90'
+pisces = '90'
+piscesb = '90'
+pitnrun = '90'
+pitnruna = '90'
+playball = '270'
+pleiadbl = '90'
+pleiadce = '90'
+pleiads = '90'
+pleiadsb2 = '90'
+pleiadsi = '90'
+pleiadsn = '90'
+pleiadss = '90'
+plgirls = '270'
+plgirls2 = '270'
+plgirls2b = '270'
+plusalph = '270'
+plygonet = '90'
+pntnpuzl = '90'
+polaris = '270'
+polarisa = '270'
+polarisb = '270'
+polarisbr = '270'
+polariso = '270'
+pollux = '270'
+polluxa = '270'
+polluxa2 = '270'
+polluxn = '270'
+polynetw = '90'
+pootan = '90'
+pooyan = '90'
+pooyans = '90'
+popeyeman = '90'
+popflame = '90'
+popflamea = '90'
+popflameb = '90'
+popflamen = '90'
+popper = '90'
+porky = '90'
+porter = '90'
+portera = '90'
+portman = '90'
+portmanj = '90'
+portrait = '270'
+portraita = '270'
+poundfor = '270'
+poundforj = '270'
+poundforu = '270'
+ppking = '90'
+pprobe = '90'
+progolf = '270'
+progolfa = '270'
+progress = '90'
+prosoccr = '270'
+prosport = '270'
+prosporta = '270'
+protenn = '270'
+protennb = '270'
+prtytime = '90'
+psurge = '270'
+psychic5 = '270'
+psychic5j = '270'
+psyvar2 = '270'
+psyvaria = '270'
+psyvarij = '270'
+psyvarrv = '270'
+pturn = '90'
+puckman = '90'
+puckmanb = '90'
+puckmanf = '90'
+puckmanh = '90'
+puckmod = '90'
+pulsar = '270'
+puzlclub = '90'
+pwrflip = '270'
+pzlestar = '270'
+qbert = '270'
+qberta = '270'
+qbertj = '270'
+qbertqub = '270'
+qberttst = '270'
+qix = '270'
+qix2 = '270'
+qixa = '270'
+qixb = '270'
+qixo = '270'
+quaak = '90'
+quantum = '270'
+quantum1 = '270'
+quantump = '270'
+quarterb = '270'
+quarterba = '270'
+quarterbc = '270'
+quasar = '90'
+quasara = '90'
+quester = '90'
+questers = '90'
+qwak = '270'
+r2dtank = '270'
+r2dx_v33 = '270'
+r2dx_v33_r2 = '270'
+raaspec = '270'
+rackemup = '90'
+radarscp = '270'
+radarscp1 = '270'
+radarscpc = '270'
+radarzon = '90'
+radarzon1 = '90'
+radarzont = '90'
+radirgy = '270'
+radirgyo = '270'
+raflesia = '270'
+raflesiau = '270'
+raiden = '270'
+raiden2 = '270'
+raiden2dx = '270'
+raiden2e = '270'
+raiden2ea = '270'
+raiden2eg = '270'
+raiden2es = '270'
+raiden2eu = '270'
+raiden2eua = '270'
+raiden2eub = '270'
+raiden2eup = '270'
+raiden2f = '270'
+raiden2g = '270'
+raiden2hk = '270'
+raiden2i = '270'
+raiden2j = '270'
+raiden2k = '270'
+raiden2nl = '270'
+raiden2sw = '270'
+raiden2u = '270'
+raidena = '270'
+raidenb = '270'
+raidendx = '270'
+raidendxa1 = '270'
+raidendxa2 = '270'
+raidendxch = '270'
+raidendxg = '270'
+raidendxj = '270'
+raidendxja = '270'
+raidendxk = '270'
+raidendxnl = '270'
+raidendxpt = '270'
+raidendxu = '270'
+raidenk = '270'
+raidenkb = '270'
+raidenm = '270'
+raident = '270'
+raidenu = '270'
+raidenua = '270'
+raidenub = '270'
+raiders = '90'
+raidersr3 = '90'
+rallybik = '270'
+raphero = '270'
+rapheroa = '270'
+rayforce = '90'
+rayforcej = '90'
+razmataz = '90'
+rcorsair = '90'
+rdft = '270'
+rdft2 = '270'
+rdft22kc = '270'
+rdft2a = '270'
+rdft2aa = '270'
+rdft2it = '270'
+rdft2j = '270'
+rdft2ja = '270'
+rdft2jb = '270'
+rdft2jc = '270'
+rdft2s = '270'
+rdft2t = '270'
+rdft2u = '270'
+rdft2us = '270'
+rdfta = '270'
+rdftadi = '270'
+rdftam = '270'
+rdftau = '270'
+rdftauge = '270'
+rdftgb = '270'
+rdftgr = '270'
+rdftit = '270'
+rdftj = '270'
+rdftja = '270'
+rdftjb = '270'
+rdfts = '270'
+rdftu = '270'
+rdftua = '270'
+reaktor = '90'
+redalert = '270'
+redclash = '270'
+redclashs = '270'
+redclasht = '270'
+redclashta = '270'
+redfoxwp2 = '270'
+redfoxwp2a = '270'
+redhawk = '270'
+redhawke = '270'
+redhawkk = '270'
+redlin2p = '270'
+redrobin = '90'
+redufo = '270'
+redufob = '90'
+redufob2 = '90'
+regulus = '270'
+reguluso = '270'
+regulusu = '270'
+repulse = '90'
+resclunar = '270'
+rescue = '90'
+rescueb = '90'
+rescuefe = '90'
+retofinv = '90'
+retofinvb = '90'
+retofinvb1 = '90'
+retofinvb2 = '90'
+retofinvb3 = '90'
+revenger = '270'
+revngr84 = '270'
+rfjet = '270'
+rfjet2kc = '270'
+rfjeta = '270'
+rfjetj = '270'
+rfjets = '270'
+rfjetsa = '270'
+rfjett = '270'
+rfjetu = '270'
+ridleofp = '90'
+ringfgt = '90'
+ringfgt2 = '90'
+ringking = '90'
+ringking2 = '90'
+ringking3 = '90'
+ringkingw = '90'
+roadf = '90'
+roadf2 = '90'
+roadfh = '90'
+roadfu = '90'
+rockduck = '270'
+rockn = '270'
+rockn2 = '270'
+rockn3 = '270'
+rockn4 = '270'
+rockna = '270'
+rocknms = '270'
+rocnrope = '270'
+rocnropek = '270'
+rollace = '270'
+rollace2 = '90'
+rollingc = '270'
+rompers = '90'
+romperso = '90'
+ropeman = '270'
+rotaryf = '270'
+roundup = '90'
+route16 = '270'
+route16a = '270'
+route16b = '270'
+route16bl = '270'
+route16c = '270'
+route16d = '270'
+routex = '270'
+routexa = '270'
+rshark = '270'
+rugrats = '90'
+rumba = '270'
+runaways = '270'
+rundeep = '270'
+rushcrsh = '270'
+ryujin = '270'
+ryujina = '270'
+s1945 = '270'
+s1945a = '270'
+s1945bl = '270'
+s1945ii = '270'
+s1945iii = '270'
+s1945j = '270'
+s1945k = '270'
+s1945n = '270'
+s1945nj = '270'
+safarir = '90'
+safarirj = '90'
+samesame = '270'
+samesame2 = '270'
+samesamecn = '270'
+samesamenh = '270'
+samurai = '270'
+samuraia = '270'
+sandscrp = '90'
+sandscrpa = '90'
+sandscrpb = '90'
+sasuke = '90'
+satansat = '90'
+satansata = '90'
+satansatind = '90'
+saturnzi = '270'
+savanna = '90'
+savgbees = '270'
+sbagman = '270'
+sbagman2 = '270'
+sbagmani = '90'
+sbagmans = '270'
+sbasketb = '90'
+sbaskete = '90'
+sbasketg = '90'
+sbasketh = '90'
+sbdk = '270'
+sbomber = '270'
+sbombera = '270'
+sbowling = '90'
+sbrkout = '270'
+sbrkout3 = '270'
+sbrkoutc = '270'
+sbrkoutct = '270'
+sbugger = '270'
+sbuggera = '270'
+schaser = '270'
+schasera = '270'
+schaserb = '270'
+schaserc = '270'
+schasercv = '270'
+schaserm = '270'
+schick = '90'
+scobra = '90'
+scobrab = '90'
+scobrae = '90'
+scobrae2 = '90'
+scobrag = '90'
+scobraggi = '90'
+scobras = '90'
+scobrase = '90'
+scontra = '90'
+scontraa = '90'
+scontraj = '90'
+scorpion = '90'
+scorpiona = '90'
+scorpionb = '90'
+scorpionmc = '90'
+scotrsht = '90'
+scramb2 = '90'
+scramb3 = '90'
+scramblb = '90'
+scramble = '90'
+scramblebb = '90'
+scramblebf = '90'
+scramblebun = '90'
+scrambleo = '90'
+scrambler = '90'
+scrambles = '90'
+scrambles2 = '90'
+scrambp = '90'
+scramce = '90'
+scrammr = '90'
+scrampt = '90'
+scramrf = '90'
+scregg = '270'
+scyclone = '270'
+sdtennis = '270'
+sdungeon = '270'
+sdungeona = '270'
+searchar = '90'
+searcharj = '90'
+searcharu = '90'
+searthie = '270'
+searthin = '270'
+searthina = '270'
+sectrzon = '90'
+sectrzona = '90'
+sectrzont = '90'
+seicross = '90'
+seicrossa = '90'
+sengekis = '90'
+sengekisj = '90'
+senjyo = '90'
+sexyboom = '270'
+sfkick = '90'
+sfkicka = '90'
+sflush = '270'
+sformula = '90'
+sformulaa = '90'
+shaolinb = '90'
+shaolins = '90'
+sheriff = '270'
+shienryu = '270'
+shikgam2 = '270'
+shikigam = '270'
+shikigama = '270'
+shippumd = '270'
+shollow = '90'
+shollow2 = '90'
+shootbul = '90'
+shtrider = '270'
+shtridera = '270'
+shtriderb = '270'
+shuffle = '90'
+shuttlei = '270'
+sia2650 = '270'
+sianniv = '270'
+sicv = '270'
+sicv1 = '270'
+silvmil = '270'
+sindbadm = '270'
+sinistar = '270'
+sinistar2 = '270'
+sinistarp = '270'
+sinvasn = '270'
+sinvasnb = '270'
+sinvemag = '270'
+sinvzen = '270'
+sirio2 = '90'
+sisv = '270'
+sisv1 = '270'
+sisv2 = '270'
+sisv3 = '270'
+sitv = '270'
+sitv1 = '270'
+skullfng = '270'
+skullfnga = '270'
+skullfngj = '270'
+skyadvnt = '90'
+skyadvntj = '90'
+skyadvntu = '90'
+skyalert = '270'
+skyarmy = '90'
+skybase = '90'
+skybump = '90'
+skychut = '270'
+skyfox = '90'
+skylancr = '90'
+skylancre = '90'
+skylove = '270'
+skyraidr = '90'
+skyshark = '270'
+skysharka = '270'
+skysharkb = '270'
+skysmash = '270'
+skysoldr = '90'
+skysoldrbl = '90'
+sl2007 = '270'
+slapfigh = '270'
+slapfigha = '270'
+slapfighb1 = '270'
+slapfighb2 = '270'
+slapfighb3 = '270'
+slither = '270'
+slithera = '270'
+slspirit = '270'
+smooncrs = '90'
+sncwgltd = '270'
+sngkace = '270'
+sngkacea = '270'
+solarfox = '270'
+solarwar = '270'
+solfight = '270'
+sonicbom = '270'
+sonicbomd = '270'
+sonicwi = '270'
+sonofphx = '90'
+sos = '90'
+spacbat2 = '90'
+spacbatt = '90'
+spacbeam = '270'
+spaceat2 = '270'
+spaceatt = '270'
+spaceatt2k = '270'
+spaceattbp = '270'
+spacebrd = '270'
+spacecho = '270'
+spacecho2 = '270'
+spacecom = '270'
+spacecr = '90'
+spacedem = '270'
+spacefb = '270'
+spacefba = '270'
+spacefbb = '270'
+spacefbe = '270'
+spacefbe2 = '270'
+spacefbg = '270'
+spacefev = '270'
+spacefevo = '270'
+spacefevo2 = '270'
+spacefrt = '90'
+spaceg = '270'
+spaceint = '90'
+spaceintj = '90'
+spacelnc = '270'
+spacempr = '90'
+spaceod = '270'
+spaceod2 = '270'
+spaceph = '270'
+spaceplt = '90'
+spaceplta = '90'
+spacerng = '90'
+spacetrk = '270'
+spacetrkc = '270'
+spacewr3 = '270'
+spacmiss = '270'
+spactrai = '90'
+spbactn = '90'
+spbactnj = '90'
+spbactnp = '90'
+spcdrag = '90'
+spceking = '270'
+spcewarl = '270'
+spcewarla = '270'
+spcewars = '270'
+spcforc2 = '270'
+spcforce = '270'
+spcfrcii = '270'
+spcinv95 = '270'
+spcinv95u = '270'
+spcking2 = '270'
+spclaser = '270'
+spclforc = '270'
+spcmission = '90'
+spcpostn = '90'
+spctbird = '90'
+spctrek = '90'
+spcwarp = '90'
+spdcoin = '90'
+speakhlp = '270'
+speakres = '270'
+speakresb = '270'
+spec2k = '270'
+speedbal = '270'
+speedbala = '270'
+spfghmk2 = '90'
+spfghmk22 = '90'
+spiders = '270'
+spiders2 = '270'
+spiders3 = '270'
+spinkick = '90'
+spinner = '270'
+sprglbpg = '90'
+sprglobp = '90'
+springer = '270'
+sprpuzzle = '270'
+spyhunt = '90'
+spyhuntp = '90'
+spyhuntpr = '90'
+spyhuntsp = '90'
+sqbert = '270'
+sqix = '90'
+sqixb1 = '90'
+sqixb2 = '90'
+sqixr0 = '90'
+sqixr1 = '90'
+sqixu = '90'
+sraider = '270'
+srdarwin = '270'
+srdarwinj = '270'
+srdmissn = '90'
+srumbler = '270'
+srumbler2 = '270'
+srumbler3 = '270'
+ssi = '270'
+ssia = '270'
+ssib = '270'
+ssingles = '90'
+ssmissin = '270'
+ssozumo = '270'
+sspacaho = '270'
+sspaceat = '270'
+sspaceat2 = '270'
+sspaceat3 = '270'
+sspaceatc = '270'
+sspeedr = '270'
+sspiritj = '270'
+sspirits = '270'
+sspirtfc = '270'
+ssrj = '90'
+sstarbtl = '90'
+sstarcrs = '90'
+sstingry = '90'
+sstrangr = '270'
+sstrangr2 = '270'
+sstriker = '270'
+sstrikerk = '270'
+stagger1 = '270'
+starfght = '90'
+starfgmc = '90'
+starfigh = '90'
+starforc = '90'
+starforca = '90'
+starforcb = '90'
+starforce = '90'
+starjack = '270'
+starjacks = '270'
+starrkr = '270'
+starw = '270'
+starw1 = '270'
+starwarr = '270'
+step3 = '270'
+stepstag = '270'
+stera = '90'
+stg = '270'
+stinger = '90'
+stinger2 = '90'
+stmblade = '270'
+stmbladej = '270'
+storming = '270'
+storminga = '270'
+stratab = '270'
+stratab1 = '270'
+stratabs = '270'
+stratvox = '270'
+stratvoxa = '270'
+stratvoxb = '270'
+streakng = '90'
+streaknga = '90'
+strfbomb = '90'
+strfight = '90'
+strtheat = '270'
+strtheata = '270'
+stuntair = '90'
+sub = '270'
+sundance = '270'
+superabc = '90'
+superabco = '90'
+superbar = '270'
+superbik = '90'
+superbon = '90'
+superbug = '270'
+superg = '90'
+supergs = '90'
+supergx = '90'
+superinv = '270'
+superpac = '90'
+superpacm = '90'
+supertnk = '90'
+superwng = '90'
+superx = '270'
+superxm = '270'
+supinvsion = '270'
+supmodel = '90'
+suprglob = '270'
+suprheli = '90'
+suprmous = '90'
+suprridr = '90'
+survival = '90'
+swarm = '90'
+swat = '270'
+swipeout = '270'
+sxevious = '90'
+sxeviousj = '90'
+szaxxon = '90'
+tacscan = '270'
+tactcian = '90'
+tactcian2 = '90'
+tagteam = '270'
+tail2nos = '90'
+takeoff = '90'
+talbot = '270'
+tankbatt = '90'
+tankbattb = '90'
+tankbust = '90'
+tattack = '270'
+taxidriv = '90'
+tazmani2 = '90'
+tazmani3 = '90'
+tazmania = '90'
+tazmaniet = '90'
+tazzmang = '90'
+tazzmang2 = '90'
+tcobra2 = '270'
+tcobra2u = '270'
+tdfever = '90'
+tdfever2 = '90'
+tdfever2b = '90'
+tdfeverj = '90'
+tdragon = '270'
+tdragon1 = '270'
+tdragon2 = '270'
+tdragon2a = '270'
+tdragon3h = '270'
+tdragonb = '270'
+tdragonb2 = '270'
+teamqb = '270'
+teamqb2 = '270'
+teedoff = '90'
+telpacfl = '270'
+tempest = '270'
+tempest1 = '270'
+tempest1r = '270'
+tempest2 = '270'
+tempest3 = '270'
+temptube = '270'
+tenkomor = '90'
+tenkomorja = '90'
+tenspot = '270'
+terracre = '270'
+terracrea = '270'
+terracren = '270'
+terracreo = '270'
+tharrier = '270'
+tharrierb = '270'
+tharrieru = '270'
+the26thz = '90'
+thedeep = '270'
+theend = '90'
+theends = '90'
+theendss = '90'
+theglob = '270'
+theglob2 = '270'
+theglob3 = '270'
+theglobme = '90'
+theglobp = '90'
+thehand = '270'
+thehole = '90'
+thehustl = '90'
+thehustlj = '90'
+thepit = '90'
+thepitj = '90'
+thepitm = '90'
+thepitu1 = '90'
+thepitu2 = '90'
+theportr = '90'
+thndblst = '270'
+thunderl = '270'
+thunderlbl = '270'
+thunderlbl2 = '270'
+tigerh = '270'
+tigerhb1 = '270'
+tigerhb2 = '270'
+tigerhb3 = '270'
+tigerhb4 = '270'
+tigerhj = '270'
+timefgtr = '90'
+timelimt = '90'
+timeplt = '90'
+timeplta = '90'
+timepltc = '90'
+timescan = '270'
+timescan1 = '270'
+timesold = '90'
+timesold1 = '90'
+tinv2650 = '270'
+tiptop = '90'
+tisland = '270'
+titanpac = '90'
+tndrcade = '270'
+tndrcadej = '270'
+tnextspc = '90'
+tnextspc2 = '90'
+tnextspcj = '90'
+tnk3 = '270'
+tnk3b = '270'
+tnk3j = '270'
+todruaga = '90'
+todruagao = '90'
+todruagas = '90'
+tokio = '90'
+tokiob = '90'
+tokioo = '90'
+tokiou = '90'
+tokisens = '90'
+tokisensa = '90'
+tomahawk = '90'
+tomahawk1 = '90'
+toobin = '270'
+toobin1 = '270'
+toobin2 = '270'
+toobin2e = '270'
+toobine = '270'
+toobing = '270'
+topgunbl = '90'
+topgunr = '90'
+toprollr = '90'
+toratora = '90'
+toratorab = '90'
+torus = '90'
+toucheme = '90'
+touchemea = '90'
+tp84 = '90'
+tp84a = '90'
+tp84b = '90'
+tranqgun = '270'
+travrusa = '270'
+travrusab = '270'
+travrusab2 = '270'
+treahunt = '90'
+trgheart = '270'
+trghearto = '270'
+tricktrp = '90'
+trigon = '90'
+trikitri = '90'
+triplep = '90'
+triplepa = '90'
+tripool = '90'
+tripoola = '90'
+trisport = '270'
+trizeal = '270'
+tron = '90'
+tron2 = '90'
+tron3 = '90'
+tron4 = '90'
+tron5 = '90'
+tronger = '90'
+troopy = '90'
+truxton = '270'
+truxton2 = '270'
+tryout = '90'
+tsamurai = '90'
+tsamurai2 = '90'
+tsamuraih = '90'
+ttfitter = '90'
+tugboat = '90'
+tunhunt = '90'
+tunhuntc = '90'
+turbo = '270'
+turboa = '270'
+turbob = '270'
+turbobl = '270'
+turboc = '270'
+turbod = '270'
+turboe = '270'
+turbofrc = '270'
+turbofrco = '270'
+turbofrcu = '270'
+turbotag = '90'
+turpin = '90'
+turpins = '90'
+turtles = '90'
+tutankhm = '90'
+tutankhms = '90'
+twinbee = '90'
+twinbeeb = '90'
+twincobr = '270'
+twincobru = '270'
+twineag2 = '270'
+twineagl = '270'
+twinhawk = '270'
+twinhawku = '270'
+typhoon = '90'
+ultrainv = '270'
+ultrax = '270'
+ultraxg = '270'
+unclepoo = '90'
+undefeat = '270'
+uniwars = '90'
+uniwarsa = '90'
+upndown = '270'
+upndownu = '270'
+upyoural = '270'
+usclssic = '270'
+valkyrie = '90'
+valtric = '270'
+vandyke = '270'
+vandykeb = '270'
+vandykejal = '270'
+vandykejal2 = '270'
+vangrd2 = '270'
+vanguard = '90'
+vanguardc = '90'
+vanguardg = '90'
+vanguardj = '90'
+vanvan = '270'
+vanvanb = '270'
+vanvank = '270'
+vaportra = '270'
+vaportra3 = '270'
+vaportrau = '270'
+varth = '270'
+varthb = '270'
+varthb2 = '270'
+varthj = '270'
+varthjr = '270'
+varthr1 = '270'
+varthu = '270'
+vasara = '270'
+vasara2 = '270'
+vasara2a = '270'
+vastar = '90'
+vastar2 = '90'
+vastar3 = '90'
+vastar4 = '90'
+vautour = '90'
+vautourz = '90'
+vautourza = '90'
+vega = '270'
+venus = '90'
+verbena = '270'
+vfive = '270'
+victoryc = '270'
+victorycb = '270'
+victroad = '270'
+videight = '90'
+videopin = '270'
+videopina = '270'
+vimana = '270'
+vimanaj = '270'
+vimanan = '270'
+viprp1 = '270'
+viprp1h = '270'
+viprp1hk = '270'
+viprp1j = '270'
+viprp1k = '270'
+viprp1oj = '270'
+viprp1ot = '270'
+viprp1pt = '270'
+viprp1s = '270'
+viprp1t = '270'
+viprp1u = '270'
+viprp1ua = '270'
+vmetal = '90'
+vmetaln = '90'
+volfied = '270'
+volfiedj = '270'
+volfiedjo = '270'
+volfiedo = '270'
+volfiedu = '270'
+volfieduo = '270'
+vortex = '270'
+vpool = '90'
+vsgongf = '90'
+vulgus = '270'
+vulgusa = '90'
+vulgusj = '270'
+waidrive = '270'
+wallst = '90'
+wanted = '90'
+wantsega = '270'
+warcadia = '270'
+warofbug = '90'
+warofbugg = '90'
+warofbugu = '90'
+warp1 = '270'
+warp1bl = '270'
+warp1t = '90'
+warpwarp = '90'
+warpwarpr = '90'
+warpwarpr2 = '90'
+waterski = '270'
+westgun2 = '270'
+wiggie = '270'
+wingforc = '270'
+wiping = '90'
+wivernwg = '270'
+wiz = '270'
+wizt = '270'
+wizta = '270'
+wmatch = '270'
+wndrplnt = '270'
+woodpeca = '90'
+woodpeck = '90'
+worldwar = '270'
+wrestwar = '270'
+wrestwar1 = '270'
+wrestwar1d = '270'
+wrestwar2 = '270'
+wrestwar2d = '270'
+wrofaero = '270'
+wtennis = '270'
+ww3 = '270'
+wwestern = '270'
+wwestern1 = '270'
+wyvernf0 = '270'
+wyvernf0a = '270'
+wyvernwg = '270'
+wyvernwga = '270'
+x2222 = '270'
+x2222o = '270'
+xevios = '90'
+xevious = '90'
+xeviousa = '90'
+xeviousb = '90'
+xeviousc = '90'
+xiistag = '270'
+xxmissio = '90'
+yamagchi = '90'
+yamato = '90'
+yamato2 = '90'
+yankeedo = '270'
+yellowcbb = '270'
+yellowcbj = '270'
+yosakdon = '270'
+yosakdona = '270'
+youjyudn = '270'
+youma = '270'
+youma2 = '270'
+youmab = '270'
+youmab2 = '270'
+yukiwo = '270'
+zaryavos = '90'
+zarzon = '90'
+zaviga = '90'
+zavigaj = '90'
+zaxxon = '90'
+zaxxon2 = '90'
+zaxxon3 = '90'
+zaxxonb = '90'
+zaxxonj = '90'
+zerohour = '270'
+zerohoura = '270'
+zerohouri = '270'
+zerotime = '90'
+zerotimed = '90'
+zerotimemc = '90'
+zerotimeu = '90'
+zigzagb = '90'
+zigzagb2 = '90'
+zingzip = '270'
+zingzipbl = '270'
+zipzap = '90'
+zipzapa = '90'
+zoar = '270'
+zodiack = '270'
+zzyzzyxx = '90'
+zzyzzyxx2 = '90'
diff --git a/python-src/batocera-launch/resources/data/special/lynx.toml b/python-src/batocera-launch/resources/data/special/lynx.toml
new file mode 100644
index 00000000000..1a6339622b8
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/lynx.toml
@@ -0,0 +1,5 @@
+'Gauntlet - The Third Encounter (USA, Europe)' = '270'
+'Klax (USA, Europe)' = '270'
+'Lexis (USA)' = '90'
+'NFL Football (USA, Europe)' = '90'
+'Raiden (USA)' = '90'
diff --git a/python-src/batocera-launch/resources/data/special/mame.toml b/python-src/batocera-launch/resources/data/special/mame.toml
new file mode 100644
index 00000000000..42d19df01b8
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/mame.toml
@@ -0,0 +1,2957 @@
+1941 = '270'
+1941j = '270'
+1941r1 = '270'
+1941u = '270'
+1942 = '270'
+1942a = '270'
+1942abl = '270'
+1942b = '270'
+1942h = '270'
+1942p = '270'
+1942w = '270'
+1943 = '270'
+1943b = '270'
+1943bj = '270'
+1943j = '270'
+1943ja = '270'
+1943jah = '270'
+1943kai = '270'
+1943mii = '270'
+1943u = '270'
+1943ua = '270'
+1945kiii = '270'
+1945kiiin = '270'
+1945kiiio = '270'
+19in1 = '90'
+19xx = '270'
+19xxa = '270'
+19xxar1 = '270'
+19xxb = '270'
+19xxd = '270'
+19xxh = '270'
+19xxj = '270'
+19xxjr1 = '270'
+19xxjr2 = '270'
+19xxu = '270'
+20pacgal = '90'
+20pacgalr0 = '90'
+20pacgalr1 = '90'
+20pacgalr2 = '90'
+20pacgalr3 = '90'
+20pacgalr4 = '90'
+25pacman = '90'
+25pacmano = '90'
+39in1 = '90'
+48in1 = '90'
+48in1a = '90'
+48in1b = '90'
+48in1c = '90'
+4in1 = '90'
+4in1a = '90'
+4in1b = '90'
+5 = '270'
+600 = '90'
+60in1 = '90'
+800fath = '90'
+800fatha = '90'
+8ball = '90'
+8ball1 = '90'
+8ballact = '270'
+8ballact2 = '270'
+8bpm = '90'
+99lstwar = '90'
+99lstwara = '90'
+99lstwarb = '90'
+99lstwark = '90'
+aafb = '270'
+aafbb = '270'
+aafbc = '270'
+aafbd2p = '270'
+abattle = '90'
+abattle2 = '90'
+abscam = '90'
+aceattac = '270'
+aceattaca = '270'
+acombat = '90'
+acombat3 = '90'
+acombat4 = '90'
+acombato = '90'
+acrobatm = '270'
+ad2083 = '90'
+aerfboo2 = '270'
+aerfboot = '270'
+aerofgt = '270'
+aerofgtb = '270'
+aerofgtc = '270'
+aerofgts = '270'
+afighter = '270'
+afightera = '270'
+afighterb = '270'
+afighterc = '270'
+afighterd = '270'
+afightere = '270'
+afighterf = '270'
+afighterg = '270'
+afighterh = '270'
+afire = '90'
+agallet = '270'
+agalleta = '270'
+agalletah = '270'
+agalletaj = '270'
+agalletak = '270'
+agalletat = '270'
+agalletau = '270'
+agalleth = '270'
+agalletj = '270'
+agalletk = '270'
+agallett = '270'
+agalletu = '270'
+airass = '270'
+airattck = '270'
+airattcka = '270'
+airduel = '270'
+airdueljm72 = '270'
+airduelm72 = '270'
+airduelu = '270'
+airraid = '270'
+ajax = '90'
+ajaxj = '90'
+akkanvdr = '270'
+alcon = '270'
+alibaba = '90'
+alibabab = '90'
+alieninv = '270'
+alieninvp2 = '270'
+alinvade = '90'
+alleymas = '270'
+alphaho = '270'
+alphahob = '270'
+alphamis = '270'
+alphaxz = '90'
+alpine = '270'
+alpinea = '270'
+altair = '90'
+amatelas = '270'
+amazon = '270'
+amazont = '270'
+amidar = '90'
+amidar1 = '90'
+amidarb = '90'
+amidarc = '90'
+amidaro = '90'
+amidars = '90'
+amidaru = '90'
+amigo = '90'
+amigo2 = '90'
+androidp = '90'
+androidpo = '90'
+andromed = '270'
+angelkds = '90'
+anmlbskt = '270'
+anmlbskta = '270'
+anteater = '90'
+anteaterg = '90'
+anteatergg = '90'
+anteateruk = '90'
+apb = '270'
+apb1 = '270'
+apb2 = '270'
+apb3 = '270'
+apb4 = '270'
+apb5 = '270'
+apb6 = '270'
+apbf = '270'
+apbg = '270'
+aponow = '90'
+arabian = '270'
+arabiana = '270'
+aracnis = '90'
+arbalest = '270'
+arcadian = '270'
+argus = '270'
+arian = '270'
+ark1ball = '90'
+arkangc = '90'
+arkangc2 = '90'
+arkanoid = '90'
+arkanoidj = '90'
+arkanoidja = '90'
+arkanoidjb = '90'
+arkanoidjbl = '90'
+arkanoidjbl2 = '90'
+arkanoidu = '90'
+arkanoiduo = '90'
+arkatayt = '90'
+arkatour = '90'
+arkbloc2 = '90'
+arkbloc3 = '90'
+arkblock = '90'
+arkgcbl = '90'
+arkgcbla = '90'
+arknoid2 = '270'
+arknoid2b = '270'
+arknoid2j = '270'
+arknoid2u = '270'
+arktayt2 = '90'
+armchmp2 = '270'
+armchmp2o = '270'
+armchmp2o2 = '270'
+armedf = '270'
+armedff = '270'
+armorcar = '90'
+armorcar2 = '90'
+ashura = '270'
+ashuraj = '270'
+ashurau = '270'
+asideral = '90'
+aso = '270'
+assault = '90'
+assaultj = '90'
+assaultp = '90'
+asterion = '90'
+astinvad = '270'
+astrians = '90'
+astroamb = '90'
+astrob = '270'
+astrob1 = '270'
+astrob2 = '270'
+astrob2a = '270'
+astrob2b = '270'
+astrobg = '270'
+astrof = '90'
+astrof2 = '90'
+astrof3 = '90'
+astroff = '90'
+astroff2 = '90'
+astrowar = '90'
+asuka = '270'
+asukaj = '270'
+asukaja = '270'
+asylum = '270'
+ataqandr = '90'
+atetrisc = '270'
+atetrisc2 = '270'
+atlantis = '90'
+atlantis2 = '90'
+atlantisb = '270'
+atombjt = '270'
+attckufo = '270'
+avefenix = '90'
+avefenixl = '90'
+avefenixrf = '90'
+avengers = '90'
+avengers2 = '90'
+azurian = '90'
+bagman = '270'
+bagmanf = '270'
+bagmanj = '270'
+bagmanm2 = '90'
+bagmanmc = '90'
+bagmans = '270'
+bagmans3 = '270'
+bagmans4 = '270'
+bagnard = '270'
+bagnarda = '270'
+bagnardi = '90'
+bagnardio = '90'
+bakubrkr = '90'
+ballbomb = '270'
+baluba = '90'
+bandido = '270'
+bandit = '90'
+baracuda = '90'
+barrier = '270'
+baryon = '270'
+baryona = '270'
+batman2 = '270'
+batrider = '270'
+batriderc = '270'
+batriderhk = '270'
+batriderj = '270'
+batriderja = '270'
+batriderk = '270'
+batridert = '270'
+batrideru = '270'
+batsugun = '270'
+batsuguna = '270'
+batsugunb = '270'
+batsugunsp = '270'
+battlane = '90'
+battlane2 = '90'
+battlane3 = '90'
+battles = '90'
+battles2 = '90'
+battlnts = '90'
+battlntsa = '90'
+battlntsj = '90'
+battroad = '90'
+bbakraid = '270'
+bbakraidc = '270'
+bbakraidj = '270'
+bbakraidja = '270'
+bcruzm12 = '90'
+beaminv = '270'
+beastf = '270'
+beastfp = '90'
+beezer = '90'
+beezer1 = '90'
+berenstn = '90'
+bermudat = '270'
+bermudata = '270'
+bermudatj = '270'
+bgaregga = '270'
+bgareggabl = '270'
+bgareggabla = '270'
+bgareggacn = '270'
+bgareggahk = '270'
+bgaregganv = '270'
+bgareggat = '270'
+bgareggat2 = '270'
+bgareggatw = '270'
+bigbang = '270'
+bigevglf = '270'
+bigevglfj = '270'
+bigkong = '270'
+bigprowr = '270'
+bikkuric = '90'
+billiard = '90'
+bioatack = '270'
+birdiy = '270'
+birdtry = '270'
+birdtrya = '270'
+bjtwin = '270'
+bjtwina = '270'
+bjtwinp = '270'
+bjtwinpa = '270'
+bking = '270'
+bking2 = '90'
+bking3 = '90'
+blackhol = '270'
+bladestl = '90'
+bladestle = '90'
+bladestll = '90'
+blastoff = '90'
+blazer = '90'
+blkbustr = '90'
+blkhole = '90'
+block = '270'
+block2 = '90'
+blockbl = '270'
+blockcar = '90'
+blockcarb = '90'
+blockch = '270'
+blockgal = '90'
+blockgalb = '90'
+blockj = '270'
+blockr1 = '270'
+blockr2 = '270'
+blokpong = '270'
+blswhstl = '90'
+blswhstla = '90'
+bluehawk = '270'
+bluehawkn = '270'
+blueprnt = '270'
+blueprntj = '270'
+bnj = '270'
+bnjm = '270'
+boggy84 = '90'
+boggy84b = '90'
+boggy84b2 = '90'
+bombbee = '90'
+bomber = '90'
+bombjack = '90'
+bombjack2 = '90'
+bombjackt = '90'
+bombsa = '270'
+bongo = '90'
+boobhack = '270'
+boomrang = '270'
+boomranga = '270'
+boonggab = '270'
+botanic = '90'
+botanic2 = '90'
+botanicf = '270'
+bowl3d = '90'
+bowler = '90'
+bowlrama = '270'
+brdrlinb = '270'
+brdrline = '270'
+brdrlinet = '270'
+brdrlins = '270'
+breakout = '90'
+brickzn = '90'
+brickzn11 = '90'
+brickznv4 = '90'
+brickznv5 = '90'
+brix = '90'
+bronx = '90'
+brubber = '270'
+brvblade = '270'
+brvbladea = '270'
+brvbladej = '270'
+brvbladeu = '270'
+btime = '270'
+btime2 = '270'
+btime3 = '270'
+btimem = '270'
+btlfield = '90'
+btlfieldb = '90'
+bucaner = '90'
+bucanera = '90'
+buggychl = '270'
+buggychlt = '270'
+bullfgtr = '90'
+bullfgtrs = '90'
+bullsdrt = '270'
+bullsdrtg = '90'
+buraiken = '90'
+buraikenb = '90'
+buzzard = '90'
+bwings = '90'
+bwingsa = '90'
+bwingso = '90'
+cadanglr = '270'
+cairblad = '270'
+calibr50 = '270'
+calipso = '90'
+cannballv = '270'
+cannonb = '90'
+cannonb2 = '90'
+cannonb3 = '90'
+cannonbp = '90'
+capbowl = '270'
+capbowl2 = '270'
+capbowl3 = '270'
+capbowl4 = '270'
+capitol = '90'
+caractn = '270'
+caractn2 = '270'
+carhntds = '270'
+carjmbre = '90'
+carnival = '270'
+carnivalb = '270'
+carnivalc = '270'
+carnivalca = '270'
+carnivalh = '270'
+carnivalha = '270'
+castfant = '270'
+catacomb = '90'
+catapult = '270'
+caterplr = '270'
+catnmous = '90'
+catnmousa = '90'
+cavelon = '90'
+cbdash = '270'
+cbnj = '270'
+cbtime = '270'
+cburnrub = '270'
+cburnrub2 = '270'
+cburnrubj = '270'
+cchasm = '270'
+cchasm1 = '270'
+cdiscon1 = '270'
+cencourt = '270'
+centipdb = '270'
+centipdb2 = '270'
+centipdd = '270'
+centiped = '270'
+centiped1 = '270'
+centiped2 = '270'
+centiped3 = '270'
+centipedj = '270'
+cexplore = '270'
+cfarm = '90'
+cfboy0a1 = '270'
+cfghtice = '270'
+cfishing = '270'
+cflyball = '270'
+cgraplop = '270'
+cgraplop2 = '270'
+cgraplopj = '270'
+chamburger = '270'
+champbwl = '270'
+chanbara = '270'
+checkman = '90'
+checkmanj = '90'
+cheekyms = '270'
+chinhero = '90'
+chinhero2 = '90'
+chinhero3 = '90'
+chinherot = '90'
+chkun = '90'
+chopper = '270'
+choppera = '270'
+chopperb = '270'
+chqflag = '90'
+chqflagj = '90'
+chwy = '270'
+circusc = '90'
+circusc2 = '90'
+circusc3 = '90'
+circusc4 = '90'
+circuscc = '90'
+circusce = '90'
+cityatta = '90'
+citybomb = '270'
+citybombj = '270'
+ckong = '270'
+ckongalc = '270'
+ckongcv = '90'
+ckongdks = '270'
+ckongg = '90'
+ckonggx = '90'
+ckongis = '90'
+ckongmc = '90'
+ckongmc2 = '90'
+ckongo = '270'
+ckongpt2 = '270'
+ckongpt2a = '270'
+ckongpt2b = '270'
+ckongpt2b2 = '270'
+ckongpt2j = '270'
+ckongpt2jeu = '270'
+ckongpt2ss = '270'
+ckongs = '90'
+clapapa = '270'
+clapapa2 = '270'
+clbowl = '270'
+clocknch = '270'
+clocknchj = '270'
+clubpacm = '90'
+clubpacma = '90'
+cmanhat = '270'
+cmissnx = '270'
+cmmb103 = '270'
+cmmb162 = '270'
+cnightst = '270'
+cnightst2 = '270'
+cntsteer = '270'
+cocoloco = '90'
+cocolocoa = '90'
+cocolocob = '90'
+colony7 = '270'
+colony7a = '270'
+combh = '270'
+commando = '270'
+commandob = '270'
+commandob2 = '270'
+commandob3 = '270'
+commandoj = '270'
+commandou = '270'
+commandou2 = '270'
+commsega = '90'
+complexx = '270'
+condor = '90'
+condorn = '90'
+congo = '90'
+congoa = '90'
+conquer = '90'
+contra = '90'
+contra1 = '90'
+contrab = '90'
+contrabj = '90'
+contrabj1 = '90'
+contrae = '90'
+contraj = '90'
+contraj1 = '90'
+cookrace = '270'
+coozumou = '270'
+cosmica = '270'
+cosmica1 = '270'
+cosmica22 = '270'
+cosmica23 = '270'
+cosmica2a = '270'
+cosmicg = '270'
+cosmicgi = '270'
+cosmicin = '270'
+cosmicm2 = '270'
+cosmicmo = '270'
+cosmo = '90'
+cosmogng = '90'
+cosmogngj = '90'
+cosmos = '90'
+cottong = '90'
+cowtipp = '270'
+cowtippa = '270'
+cppicf = '270'
+cppicf2 = '270'
+cprobowl = '270'
+cprogolf = '270'
+cprogolf18 = '270'
+cprogolfj = '270'
+cpsoccer = '270'
+cpsoccerj = '270'
+cptennis = '270'
+cptennisj = '270'
+crashrd = '270'
+crazyblk = '90'
+crazycop = '90'
+crbaloon = '90'
+crbaloon2 = '90'
+crockman = '90'
+crockmnf = '90'
+crshrace = '270'
+crshrace2 = '270'
+crush = '90'
+crush2 = '90'
+crush3 = '90'
+crush4 = '90'
+crush5 = '90'
+crushbl = '90'
+crushbl2 = '90'
+crushbl3 = '90'
+crushrlf = '90'
+crushs = '90'
+crzrally = '270'
+crzrallya = '270'
+crzrallyg = '270'
+crzrallyrf = '270'
+cscrtry = '270'
+cscrtry2 = '270'
+csdtenis = '270'
+cshooter = '270'
+cshootert = '270'
+cskater = '270'
+csuperas = '270'
+csweetht = '270'
+ctainv = '270'
+cterrani = '270'
+ctisland = '270'
+ctisland2 = '270'
+ctisland3 = '270'
+ctornado = '270'
+ctrpllrp = '90'
+curvebal = '270'
+cutieq = '90'
+cybattlr = '90'
+cyclshtg = '90'
+cyvern = '90'
+cyvernj = '90'
+czeroize = '270'
+dai3wksi = '270'
+daikaiju = '270'
+daioh = '270'
+daioha = '270'
+daiohc = '270'
+daiohp = '270'
+daiohp2 = '270'
+daisenpu = '270'
+dambustr = '90'
+dambustra = '90'
+dambustruk = '90'
+dangar = '270'
+dangara = '270'
+dangarb = '270'
+dangarbt = '270'
+dangarj = '270'
+dangseed = '90'
+darkmist = '270'
+darkwar = '90'
+darthvdr = '270'
+darwin = '270'
+dazzler = '90'
+dblewing = '90'
+dblewinga = '90'
+dblewingb = '90'
+ddaydoo = '270'
+ddayjlc = '90'
+ddayjlca = '90'
+ddonpach = '270'
+ddonpacha = '270'
+ddonpachj = '270'
+ddp2 = '270'
+ddp2100 = '270'
+ddp2100c = '270'
+ddp2100hk = '270'
+ddp2100j = '270'
+ddp2100k = '270'
+ddp2100t = '270'
+ddp2101 = '270'
+ddp2101c = '270'
+ddp2101hk = '270'
+ddp2101j = '270'
+ddp2101k = '270'
+ddp2101t = '270'
+ddp2c = '270'
+ddp2hk = '270'
+ddp2j = '270'
+ddp2k = '270'
+ddp2t = '270'
+ddp3 = '270'
+ddpdfk = '270'
+ddpdfk10 = '270'
+ddpdoj = '270'
+ddpdoja = '270'
+ddpdojb = '270'
+ddpdojblk = '270'
+ddpdojblka = '270'
+ddpdojblkbl = '270'
+ddpdojp = '270'
+ddpdojt = '270'
+decomult = '270'
+demoneye = '270'
+desertbr = '270'
+desertbrd = '270'
+desertbrj = '270'
+desertbrjd = '270'
+desertwr = '270'
+desterth = '270'
+destryer = '90'
+destryera = '90'
+detatwin = '90'
+devilfsg = '270'
+devilfsh = '90'
+devstors = '90'
+devstors2 = '90'
+devstorsv = '90'
+devstorsx = '90'
+devzone = '270'
+devzone2 = '270'
+dfeveron = '270'
+dfkbl = '270'
+digdug = '90'
+digdug1 = '90'
+digdug2 = '90'
+digdug2o = '90'
+digdugat = '90'
+digdugat1 = '90'
+digger = '270'
+diggerc = '90'
+digsid = '90'
+dimahoo = '270'
+dimahoou = '270'
+dimahoud = '270'
+dingo = '90'
+dingoe = '90'
+dirtfoxj = '90'
+disco = '270'
+discoboy = '270'
+discoboyp = '270'
+discof = '270'
+divebomb = '270'
+dking = '270'
+dkingjr = '270'
+dkmb = '270'
+dkong = '270'
+dkong3 = '270'
+dkong3abl = '90'
+dkong3b = '270'
+dkong3hs = '270'
+dkong3j = '270'
+dkongddk = '270'
+dkongf = '270'
+dkonghrd = '270'
+dkonghs = '270'
+dkongike = '270'
+dkongj = '270'
+dkongjnrj = '270'
+dkongjo = '270'
+dkongjo1 = '270'
+dkongjr = '270'
+dkongjr2 = '270'
+dkongjrb = '270'
+dkongjrc = '270'
+dkongjre = '270'
+dkongjrhs = '270'
+dkongjrj = '270'
+dkongjrm = '90'
+dkongjrmc = '90'
+dkongjrpb = '270'
+dkongo = '270'
+dkongpe = '270'
+dkongx = '270'
+dkongx11 = '270'
+docastle = '270'
+docastle2 = '270'
+docastleo = '270'
+dockman = '90'
+dockmanb = '90'
+dockmanc = '90'
+dogfight = '270'
+dogfightp = '270'
+dogosokb = '270'
+dogosoke = '270'
+dogyuun = '270'
+dogyuuna = '270'
+dogyuunt = '270'
+dommy = '270'
+donpachi = '270'
+donpachihk = '270'
+donpachij = '270'
+donpachijs = '270'
+donpachikr = '270'
+dorachan = '270'
+dorodon = '270'
+dorodon2 = '270'
+douni = '270'
+downtown = '270'
+downtown2 = '270'
+downtownj = '270'
+downtownp = '270'
+draco = '90'
+dragnblz = '270'
+drakton = '270'
+dremshpr = '270'
+driftout = '270'
+driftoutct = '270'
+driftoutj = '270'
+driveout = '270'
+drivfrcb = '90'
+drivfrcg = '90'
+drivfrcp = '90'
+drivfrcsg = '90'
+drivfrcsga = '90'
+drivfrct = '90'
+drktnjr = '270'
+drmicro = '270'
+drtoppel = '90'
+drtoppelj = '90'
+drtoppelu = '90'
+dsaber = '90'
+dsabera = '90'
+dsaberj = '90'
+dspirit = '90'
+dspirit1 = '90'
+dspirit2 = '90'
+dualaslt = '270'
+dyger = '270'
+dygera = '270'
+dynamski = '90'
+dzigzag = '90'
+eagle = '90'
+eagle2 = '90'
+eagle3 = '90'
+earthjkr = '270'
+earthjkra = '270'
+earthjkrb = '270'
+earthjkrp = '270'
+eeekk = '270'
+eeekkp = '90'
+efdt = '90'
+eggor = '90'
+eggs = '270'
+eightfrc = '90'
+elecyoyo = '270'
+elecyoyo2 = '270'
+enigma2 = '270'
+enigma2a = '270'
+enigma2b = '270'
+equites = '90'
+equitess = '90'
+escmars = '270'
+espgal = '270'
+espgal2 = '270'
+espgal2a = '270'
+espgalbl = '270'
+esprade = '270'
+espradej = '270'
+espradejo = '270'
+evilston = '270'
+excthour = '270'
+exctscc2 = '270'
+exctsccr = '270'
+exctsccra = '270'
+exctsccrb = '270'
+exctsccrj = '270'
+exctsccrjo = '270'
+exctsccru = '270'
+exedexes = '270'
+exerion = '90'
+exerionb = '90'
+exerionb2 = '90'
+exerionba = '90'
+exeriont = '90'
+exerizer = '90'
+exerizerb = '90'
+exodus = '90'
+explbrkr = '90'
+explbrkrk = '90'
+explorer = '90'
+extrmatn = '270'
+extrmatnj = '270'
+extrmatnu = '270'
+extrmatnur = '270'
+eyes = '90'
+eyes2 = '90'
+eyesb = '90'
+eyeszac = '90'
+eyeszacb = '90'
+f1gp = '90'
+f1gp2 = '90'
+f1gpa = '90'
+f1gpb = '90'
+fa = '90'
+falcon = '90'
+falconz = '90'
+fantasia = '90'
+fantasiaa = '90'
+fantasiab = '90'
+fantasian = '90'
+fantastc = '90'
+fantasyg = '90'
+fantasyg2 = '90'
+fantasyj = '90'
+fantasyu = '90'
+fantazia = '90'
+fantsy95 = '90'
+fastdraw = '90'
+fastdraw_130 = '90'
+fastfred = '90'
+fastlane = '90'
+fcombat = '90'
+fenix = '90'
+fenixn = '90'
+feversos = '270'
+fghtatck = '90'
+fgoal = '90'
+fgoala = '90'
+fhawk = '270'
+fhawkj = '270'
+fi6845 = '270'
+fi8275 = '270'
+fightrol = '270'
+finalizr = '90'
+finalizra = '90'
+finalizrb = '90'
+firebarr = '270'
+firebatl = '90'
+fireshrk = '270'
+fireshrka = '270'
+fireshrkd = '270'
+fireshrkdh = '270'
+firetrap = '90'
+firetrapa = '90'
+firetrapbl = '90'
+firetrapj = '90'
+firetrk = '270'
+fitter = '90'
+fitterbl = '90'
+fiveside = '90'
+fixeight = '270'
+fixeighta = '270'
+fixeightat = '270'
+fixeightbl = '270'
+fixeighth = '270'
+fixeightht = '270'
+fixeightj = '270'
+fixeightjt = '270'
+fixeightk = '270'
+fixeightkt = '270'
+fixeightt = '270'
+fixeighttw = '270'
+fixeighttwt = '270'
+fixeightu = '270'
+fixeightut = '270'
+flipjack = '270'
+flkatck = '90'
+flkatcka = '90'
+floritas = '90'
+floritasm = '90'
+flyboy = '90'
+flyboyb = '90'
+flytiger = '270'
+flytigera = '270'
+fnkyfish = '90'
+fnshark = '270'
+fourplay = '90'
+freekick = '270'
+freekicka = '270'
+freekickb1 = '270'
+freekickb2 = '270'
+freekickb3 = '270'
+freeze = '90'
+frogf = '90'
+frogg = '90'
+frogger = '90'
+froggeram = '90'
+froggermc = '90'
+froggers = '90'
+froggers1 = '90'
+froggers2 = '90'
+froggers3 = '90'
+froggerv = '90'
+froggervd = '90'
+froggrs = '90'
+frontlin = '270'
+frontlina = '270'
+fshark = '270'
+fsharkb = '270'
+fsharkbt = '270'
+fspiderb = '90'
+fstarfrc = '90'
+fstarfrcj = '90'
+fstarfrcw = '90'
+funkybee = '90'
+funkybeeb = '90'
+funnymou = '90'
+futari10 = '270'
+futari15 = '270'
+futari15a = '270'
+futaribl = '270'
+futariblj = '270'
+futflash = '270'
+futspy = '90'
+fx = '90'
+gaiapols = '90'
+gaiapolsj = '90'
+gaiapolsu = '90'
+galactic = '270'
+galaga = '90'
+galaga3 = '90'
+galaga3a = '90'
+galaga3b = '90'
+galaga3c = '90'
+galaga3m = '90'
+galaga88 = '270'
+galaga88a = '90'
+galaga88j = '90'
+galagamf = '90'
+galagamk = '90'
+galagamw = '90'
+galagao = '90'
+galaktron = '90'
+galap1 = '90'
+galap4 = '90'
+galapx = '90'
+galaxbsf = '90'
+galaxbsf2 = '90'
+galaxcirsa = '90'
+galaxia = '90'
+galaxiaa = '90'
+galaxiab = '90'
+galaxiac = '90'
+galaxian = '90'
+galaxiana = '90'
+galaxianbl = '90'
+galaxianbl2 = '90'
+galaxianbl3 = '90'
+galaxianem = '90'
+galaxiani = '90'
+galaxianm = '90'
+galaxianmo = '90'
+galaxianrp = '90'
+galaxiant = '90'
+galaxrcgg = '90'
+galaxrf = '90'
+galaxrfgg = '90'
+galaxygn = '90'
+galaxygnh = '90'
+galaxyx = '90'
+galemp = '90'
+galgame = '270'
+galivan = '270'
+galivan2 = '270'
+galivan3 = '270'
+galkamika = '90'
+gallag = '90'
+galmedes = '270'
+galmonst = '270'
+galpani2 = '90'
+galpani2e = '90'
+galpani2e2 = '90'
+galpani2g = '90'
+galpani2gs = '90'
+galpani2i = '90'
+galpani2i2 = '90'
+galpani2j = '90'
+galpani2t = '90'
+galpani3 = '90'
+galpani3hk = '90'
+galpani3j = '90'
+galpani3k = '90'
+galpanic = '90'
+galpanica = '90'
+galpanicb = '90'
+galpanicms = '90'
+galsnew = '90'
+galsnewj = '90'
+galsnewk = '90'
+galsnewt = '90'
+galsnewu = '90'
+galspnbl = '90'
+galturbo = '90'
+galxwars = '270'
+galxwars2 = '270'
+galxwarst = '270'
+galxwarst2 = '270'
+gametngk = '270'
+gangonta = '90'
+gaplus = '90'
+gaplusa = '90'
+gaplusd = '90'
+gaplust = '90'
+gardia = '270'
+gardiab = '270'
+gardiaj = '270'
+garuka = '90'
+gatsbee = '90'
+gbusters = '90'
+gbustersa = '90'
+gcpinbal = '270'
+geebee = '90'
+geebeea = '90'
+geebeeb = '90'
+geebeeg = '90'
+gekiridn = '270'
+gekiridnj = '270'
+gekisou = '90'
+gemcrush = '270'
+gemini = '90'
+geminib = '90'
+geminij = '90'
+getaway = '270'
+gground = '270'
+ggroundj = '270'
+ghostmun = '90'
+ghox = '270'
+ghoxj = '270'
+ghoxjo = '270'
+gigas = '270'
+gigasb = '270'
+gigasm2 = '270'
+gigasm2b = '270'
+gmahou = '270'
+gmgalax = '90'
+gnbarich = '270'
+goindol = '90'
+goindolj = '90'
+goindolu = '90'
+goldbug = '90'
+gondo = '270'
+gondou = '270'
+gorf = '270'
+gorfpgm1 = '270'
+gorfpgm1g = '270'
+gorkans = '90'
+gotya = '270'
+gp2quiz = '90'
+gp2se = '90'
+grasspin = '270'
+grchamp = '270'
+grchampa = '270'
+grchampb = '270'
+grdnstrmg = '270'
+grdnstrmj = '270'
+grdnstrmk = '270'
+grdnstrmv = '270'
+greenber = '270'
+grescue = '270'
+griffon = '90'
+griffono = '90'
+grindstm = '270'
+grindstma = '270'
+grobda = '90'
+grobda2 = '90'
+grobda3 = '90'
+gryzor = '90'
+gryzor1 = '90'
+gseeker = '90'
+gseekerj = '90'
+gseekeru = '90'
+gstream = '270'
+gteikoku = '90'
+gteikokub = '270'
+gteikokub2 = '90'
+gteikokub3 = '90'
+gulfstrm = '270'
+gulfstrma = '270'
+gulfstrmb = '270'
+gulfstrmk = '270'
+gulfstrmm = '270'
+gulfwar2 = '270'
+gulfwar2a = '270'
+gunball = '270'
+gunbird = '270'
+gunbird2 = '270'
+gunbird2a = '270'
+gunbirdj = '270'
+gunbirdk = '270'
+gundealr = '270'
+gundealra = '270'
+gundealrbl = '270'
+gundealrt = '270'
+gundhara = '270'
+gundharac = '270'
+gunfront = '270'
+gunfrontj = '270'
+gunlock = '90'
+gunlocko = '270'
+gunnail = '270'
+gunnailb = '270'
+gunnailp = '270'
+gunsmoke = '270'
+gunsmokeb = '270'
+gunsmokeg = '270'
+gunsmokej = '270'
+gunsmokeu = '270'
+gunsmokeua = '270'
+gunsmokeub = '270'
+gutangtn = '90'
+guttangt = '90'
+guttangts3 = '90'
+guwange = '270'
+guwanges = '270'
+guzzler = '90'
+guzzlers = '90'
+gwar = '270'
+gwara = '270'
+gwarab = '270'
+gwarb = '270'
+gwarj = '270'
+gyrodine = '90'
+gyrodinet = '90'
+gyruss = '90'
+gyrussb = '90'
+gyrussce = '90'
+hal21 = '270'
+hal21j = '270'
+halleysc = '90'
+halleysc87 = '90'
+halleyscj = '90'
+halleyscja = '90'
+halleyscjp = '90'
+hamaway = '90'
+hangly = '90'
+hangly2 = '90'
+hangly3 = '90'
+harem = '90'
+hbarrel = '270'
+hbarrelu = '270'
+headoni = '270'
+headonn = '270'
+heartatk = '90'
+heiankyo = '270'
+helifire = '270'
+helifirea = '270'
+herbiedk = '270'
+hero = '90'
+herodk = '270'
+herodku = '270'
+hexpool = '90'
+hexpoola = '90'
+highsplt = '270'
+highsplta = '270'
+highspltb = '270'
+hishouza = '270'
+hishouzab = '270'
+hncholms = '90'
+hoccer = '90'
+hoccer2 = '90'
+homo = '90'
+hopprobo = '90'
+horekid = '270'
+horekidb = '270'
+horshoes = '270'
+hotdogst = '90'
+hotnight = '90'
+hotpinbl = '90'
+hotshock = '90'
+hotshockb = '90'
+hotsmash = '90'
+hstennis = '90'
+hstennis10 = '90'
+hunchbak = '90'
+hunchbaka = '90'
+hunchbgb = '90'
+hunchbkd = '270'
+hunchbkg = '90'
+hunchbks = '90'
+hunchbks2 = '90'
+huncholy = '90'
+hustler = '90'
+hustlerb = '90'
+hustlerb2 = '90'
+hustlerb3 = '90'
+hustlerb4 = '90'
+hustlerb5 = '90'
+hustlerb6 = '90'
+hustlerd = '90'
+hwrace = '270'
+ibara = '270'
+ibarablk = '270'
+ibarablka = '270'
+igmo = '270'
+ikari = '270'
+ikaria = '270'
+ikaria2 = '270'
+ikarijp = '270'
+ikarijpb = '270'
+ikarinc = '270'
+ikariram = '270'
+ikaruga = '270'
+illvelo = '270'
+imago = '90'
+imagoa = '90'
+imgfight = '270'
+imgfightb = '270'
+imgfightj = '270'
+imolagp = '90'
+imolagpo = '90'
+indianbt = '270'
+indianbtbr = '270'
+indy800 = '90'
+intrepid = '90'
+intrepid2 = '90'
+intrepidb = '90'
+intrepidb2 = '90'
+intrepidb3 = '90'
+intruder = '270'
+invad2ct = '90'
+invaddlx = '270'
+invader4 = '270'
+invaderl = '270'
+invadernc = '270'
+invaders = '270'
+invadersem = '270'
+invadpt2 = '270'
+invadpt2a = '270'
+invadpt2br = '270'
+invadrmr = '270'
+invasion = '270'
+invasiona = '270'
+invasiona2 = '270'
+invasionb = '270'
+invasionrz = '270'
+invasionrza = '270'
+invcarht = '270'
+invds = '270'
+invho2 = '270'
+invho2a = '270'
+invinco = '270'
+invmulti = '270'
+invmultim1a = '270'
+invmultim2a = '270'
+invmultim2c = '270'
+invmultim3a = '270'
+invmultip = '270'
+invmultis1a = '270'
+invmultis2a = '270'
+invmultis3a = '270'
+invmultit3d = '270'
+invqix = '270'
+invrvnge = '270'
+invrvngea = '270'
+invrvngeb = '270'
+invrvngedu = '270'
+invrvngegw = '270'
+ipminvad = '270'
+ipminvad1 = '270'
+irion = '90'
+ixion = '270'
+jack = '90'
+jack2 = '90'
+jack3 = '90'
+jackal = '90'
+jackalbl = '90'
+jackalj = '90'
+jackalr = '90'
+jackler = '90'
+jackrabt = '90'
+jackrabt2 = '90'
+jackrabts = '90'
+jcross = '270'
+jcrossa = '270'
+jin = '90'
+jjack = '270'
+joinem = '90'
+jollyjgr = '90'
+jongbou = '90'
+josvolly = '90'
+journey = '90'
+joust2 = '270'
+joust2r1 = '270'
+joyfulr = '270'
+joyman = '90'
+jrking = '270'
+jrpacman = '90'
+jrpacmanf = '90'
+jrpacmbl = '90'
+jspecter = '270'
+jspecter2 = '270'
+jumpbug = '90'
+jumpbugb = '90'
+jumpcoas = '90'
+jumpcoasa = '90'
+jumpcoast = '90'
+jumpkun = '90'
+jumpshot = '90'
+jumpshotp = '90'
+jungler = '90'
+junglero = '90'
+junglers = '90'
+jungsub = '90'
+junofrst = '90'
+junofrstg = '90'
+kageki = '90'
+kagekih = '90'
+kagekij = '90'
+kagekiu = '90'
+kaitei = '90'
+kaitein = '90'
+kamakazi3 = '90'
+kamikaze = '270'
+kamikazesp = '90'
+kamikazp = '90'
+kamikcab = '270'
+kangaroo = '90'
+kangarooa = '90'
+kangaroob = '90'
+kangarool = '90'
+kaos = '270'
+karateda = '90'
+karatedo = '90'
+karatevs = '90'
+karous = '270'
+kbh = '270'
+kbm = '270'
+kbm2nd = '270'
+kbm3rd = '270'
+kchamp = '90'
+kchamptec = '90'
+kchampvs = '90'
+kchampvs2 = '90'
+kchampvs3 = '90'
+kchampvs4 = '90'
+ket = '270'
+ket1 = '270'
+keta = '270'
+ketarr = '270'
+ketarr10 = '270'
+ketarr15 = '270'
+ketarr151 = '270'
+ketarrf = '270'
+ketarrs15 = '270'
+ketarrs151 = '270'
+ketb = '270'
+ketbl = '270'
+kick = '90'
+kickc = '90'
+kicker = '90'
+kickman = '90'
+kikikai = '90'
+kingball = '90'
+kingballj = '90'
+kingdmgp = '270'
+kingofb = '90'
+kingofbj = '90'
+knightb = '90'
+knockout = '90'
+knockoutb = '90'
+knockoutc = '90'
+komemokos = '270'
+konam80a = '90'
+konam80j = '90'
+konam80k = '90'
+konam80s = '90'
+konam80u = '90'
+kong = '90'
+korosuke = '90'
+kosmokil = '270'
+krull = '270'
+krzybowl = '270'
+ktiger = '270'
+ktiger2 = '270'
+kuhga = '270'
+kyros = '90'
+kyrosj = '90'
+labyrunr = '90'
+labyrunrk = '90'
+ladybug = '270'
+ladybugb = '270'
+ladybugb2 = '270'
+ladybugg = '270'
+ladyfrog = '90'
+ladykill = '90'
+ladymstr = '90'
+ladymstr2 = '90'
+lagirl = '270'
+lagunar = '90'
+laser = '270'
+laserbas = '270'
+laserbasa = '270'
+lasso = '90'
+lastday = '270'
+lastdaya = '270'
+lastduel = '270'
+lastduelb = '270'
+lastduelj = '270'
+lastduelo = '270'
+lastmisn = '270'
+lastmisnj = '270'
+lastmisnu5 = '270'
+lastmisnu6 = '270'
+leds2011 = '270'
+leds2011p = '270'
+leds2011u = '270'
+ledstorm = '270'
+legion = '270'
+legionj = '270'
+legionjb = '270'
+legionjb2 = '270'
+legofair = '270'
+lethalth = '270'
+levers = '90'
+lgtnfght = '90'
+lgtnfghta = '90'
+lgtnfghtu = '90'
+liberate = '270'
+liberateb = '270'
+lizwiz = '90'
+lnc = '270'
+locoboot = '90'
+locomotn = '90'
+logger = '90'
+loggerr2 = '90'
+looping = '90'
+loopingv = '90'
+loopingva = '90'
+losttomb = '90'
+losttombh = '90'
+loverboy = '90'
+lrescue = '270'
+lrescuem = '270'
+lrescuem2 = '270'
+lsasquad = '270'
+lupin3 = '270'
+lupin3a = '270'
+lwings = '90'
+lwings2 = '90'
+lwingsb = '90'
+lwingsj = '90'
+lwingsja = '90'
+m660 = '90'
+m660b = '90'
+m660j = '90'
+mach9 = '270'
+machomou = '90'
+macross = '270'
+macrossp = '270'
+madalien = '270'
+madaliena = '270'
+madalienb = '270'
+madgear = '270'
+madgearj = '270'
+madshark = '270'
+mag_day = '90'
+mag_time = '90'
+mag_war = '90'
+mag_wara = '90'
+magspot = '270'
+magspot2 = '270'
+maguila = '270'
+magworm = '270'
+magworma = '270'
+mahoudai = '270'
+majest12j = '270'
+majest12u = '270'
+maketrax = '270'
+maketrxb = '270'
+makyosen = '270'
+mamonoro = '270'
+mandinga = '90'
+mandingac = '90'
+mandingaeg = '90'
+mandingarf = '90'
+maniach = '270'
+maniach2 = '270'
+manybloc = '270'
+mappy = '90'
+mappyj = '90'
+marinedt = '270'
+mariner = '90'
+mars = '90'
+marsarf = '90'
+marvins = '270'
+masterw = '270'
+masterwj = '270'
+masterwu = '270'
+matmania = '270'
+mazeinv = '270'
+mazinger = '90'
+mazingerj = '90'
+mbrush = '90'
+megadon = '270'
+megaforc = '90'
+megaforcu = '90'
+megazone = '90'
+megazonea = '90'
+megazoneb = '90'
+megazoneh = '90'
+megazonei = '90'
+megazonej = '90'
+mercenario = '270'
+mercs = '270'
+mercsj = '270'
+mercsu = '270'
+mercsur1 = '270'
+merlinmm = '90'
+metafox = '270'
+meteor = '270'
+meteora = '90'
+metlfrzr = '270'
+metlhawk = '90'
+metlhawkj = '90'
+mgolf = '270'
+mightguy = '270'
+mikie = '270'
+mikiehs = '270'
+mikiej = '270'
+mikiek = '270'
+millipdd = '270'
+milliped = '270'
+millpac = '270'
+mimonkey = '90'
+mimonkeyug = '90'
+mimonsco = '90'
+mimonscr = '90'
+mimonscra = '90'
+minefld = '90'
+minefldfe = '90'
+mirax = '90'
+miraxa = '90'
+misncrft = '90'
+misncrfta = '90'
+mjleague = '270'
+mlander = '270'
+mltiwars = '90'
+mmagic = '270'
+mmonkey = '270'
+mmonkeyj = '270'
+mmpork = '270'
+mnchmobl = '270'
+mocapb = '90'
+mocapbj = '90'
+mocapglf = '90'
+moegonta = '90'
+mofflott = '270'
+moguchan = '270'
+monkeyd = '270'
+monsterb = '270'
+monsterb2 = '270'
+montecar = '270'
+monymony = '90'
+monymony2 = '90'
+monzagp = '270'
+monzagpb = '270'
+moonal2 = '90'
+moonal2b = '90'
+moonaln = '90'
+moonbase = '270'
+moonbasea = '270'
+mooncmw = '90'
+mooncptc = '90'
+mooncreg = '90'
+mooncrgx = '270'
+mooncrs2 = '90'
+mooncrs3 = '90'
+mooncrs4 = '90'
+mooncrs5 = '90'
+mooncrsb = '90'
+mooncrsl = '90'
+mooncrst = '90'
+mooncrstg = '90'
+mooncrsto = '90'
+mooncrstso = '90'
+mooncrstu = '90'
+mooncrstuk = '90'
+mooncrstuku = '90'
+mooncrstuu = '90'
+moonqsr = '90'
+moonwar = '90'
+moonwara = '90'
+motorace = '270'
+motos = '90'
+mototour = '270'
+mouncrst = '90'
+mouser = '90'
+mouserc = '90'
+mplanets = '270'
+mplanetsuk = '270'
+mrdo = '270'
+mrdobl = '270'
+mrdofabr = '270'
+mrdofix = '270'
+mrdot = '270'
+mrdoy = '270'
+mrdu = '270'
+mrflea = '270'
+mrjong = '90'
+mrkougar = '90'
+mrkougar2 = '90'
+mrkougb = '90'
+mrkougb2 = '90'
+mrlo = '270'
+mrsdyna = '270'
+mrtnt = '90'
+mrviking = '270'
+mrvikingj = '270'
+mschamp = '90'
+mschamps = '90'
+msheartb = '90'
+msisaac = '270'
+mspacii = '90'
+mspacii2 = '90'
+mspacmab = '90'
+mspacmab2 = '90'
+mspacmab3 = '90'
+mspacmab4 = '90'
+mspacman = '90'
+mspacmanbcc = '90'
+mspacmanbco = '90'
+mspacmanbg = '90'
+mspacmanbg2 = '90'
+mspacmanbgd = '90'
+mspacmanbhe = '90'
+mspacmanbi = '90'
+mspacmanblt = '90'
+mspacmanblt2 = '90'
+mspacmancr = '90'
+mspacmat = '90'
+mspacmbe = '90'
+mspacmbmc = '90'
+mspacmbn = '90'
+mspacmnf = '90'
+mspacpls = '90'
+mspactwin = '90'
+mspuzzle = '90'
+mspuzzlea = '90'
+mspuzzleb = '90'
+multiped = '270'
+mushisam = '270'
+mushisama = '270'
+mushisamb = '270'
+musicbal = '270'
+mustache = '90'
+mustachei = '90'
+mx5000 = '90'
+myqbert = '270'
+mystston = '270'
+myststono = '270'
+myststonoi = '270'
+naughtyb = '90'
+naughtyba = '90'
+naughtybb = '90'
+naughtybc = '90'
+navarone = '90'
+ncentury = '90'
+ncv1 = '90'
+ncv1j = '90'
+ncv1j2 = '90'
+ncv2 = '90'
+ncv2j = '90'
+nebulbee = '90'
+nebulray = '90'
+nebulrayj = '90'
+neobattl = '270'
+netwars = '90'
+newfant = '90'
+newfanta = '90'
+newpuc2 = '90'
+newpuc2b = '90'
+newpuckx = '90'
+newsin7 = '90'
+newsin7a = '90'
+nextfase = '90'
+nibbler = '90'
+nibbler6 = '90'
+nibbler7 = '90'
+nibbler8 = '90'
+nibblera = '90'
+nibblero = '90'
+nibblerp = '90'
+nightmare = '90'
+ninjemak = '270'
+nitrobal = '270'
+nitrobala = '270'
+nitrobalb = '270'
+nmouse = '90'
+nmouseb = '90'
+noahsark = '90'
+nob = '270'
+nobb = '270'
+nomnlnd = '270'
+nomnlndg = '270'
+nost = '270'
+nostj = '270'
+nostk = '270'
+nsub = '270'
+numcrash = '90'
+nunchaku = '90'
+nyny = '270'
+nynyg = '270'
+offensiv = '90'
+ohpaipee = '270'
+oigas = '270'
+olibochu = '270'
+olibug = '90'
+olmandingc = '90'
+olmandingo = '90'
+omega = '270'
+omegaa = '270'
+omegab = '270'
+omegaf = '270'
+omegafs = '270'
+omni = '90'
+ooparts = '270'
+orbite = '270'
+orbitron = '270'
+outline = '90'
+outzone = '270'
+outzonea = '270'
+outzoneb = '270'
+outzonec = '270'
+outzonecv = '270'
+outzoneh = '270'
+overdriv = '90'
+overdriva = '90'
+overdrivb = '90'
+ozmawars = '270'
+ozmawars2 = '270'
+ozmawarsmr = '270'
+ozon1 = '90'
+p911 = '90'
+p9112 = '90'
+p911ac = '90'
+p911ea = '90'
+p911ed = '90'
+p911j = '90'
+p911k = '90'
+p911kc = '90'
+p911ud = '90'
+pac90 = '90'
+pacgal = '90'
+pacgal2 = '90'
+pacheart = '90'
+pachifev = '270'
+packbang = '90'
+pacman = '90'
+pacmanbl = '270'
+pacmanbla = '270'
+pacmanblb = '90'
+pacmanblc = '270'
+pacmanblci = '270'
+pacmanblv = '270'
+pacmanf = '90'
+pacmanfm = '90'
+pacmania = '270'
+pacmaniaj = '90'
+pacmaniao = '270'
+pacmanjpm = '90'
+pacmanpe = '90'
+pacmanso = '90'
+pacmansp = '90'
+pacmanug = '90'
+pacmanvg = '90'
+pacmod = '90'
+pacnchmp = '90'
+pacnpal = '90'
+pacnpal2 = '90'
+pacominv = '270'
+pacominva = '270'
+pacplus = '90'
+pacuman = '90'
+paddle2 = '90'
+paddlema = '90'
+painter = '90'
+paintrlr = '90'
+pairlove = '270'
+pajaroes = '90'
+pandoras = '90'
+panic = '270'
+panic2 = '270'
+panic3 = '270'
+panicger = '270'
+panich = '270'
+panicr = '270'
+panicrg = '270'
+panther = '270'
+paprazzi = '270'
+para2dx = '90'
+paradise = '90'
+paradisea = '90'
+paradisee = '90'
+paradlx = '90'
+passht4b = '270'
+passsht = '270'
+passsht16a = '270'
+passshta = '270'
+passshtad = '270'
+passshtb = '270'
+passshtd = '270'
+passshtj = '270'
+passshtjd = '270'
+pbaction = '90'
+pbaction2 = '90'
+pbaction3 = '90'
+pbaction4 = '90'
+pbactiont = '90'
+pballoon = '90'
+pballoonr = '90'
+peggle = '90'
+pegglet = '90'
+pengo = '90'
+pengo2 = '90'
+pengo2u = '90'
+pengo3u = '90'
+pengo4 = '90'
+pengo5 = '90'
+pengo6 = '90'
+pengob = '90'
+pengojpm = '90'
+pengomc1 = '90'
+pengomc2 = '90'
+pengopac = '90'
+penta = '90'
+percuss = '270'
+perestro = '90'
+perestrof = '90'
+perfrman = '270'
+perfrmanu = '270'
+phelios = '90'
+pheliosj = '90'
+phoenix = '90'
+phoenix2 = '90'
+phoenix3 = '90'
+phoenixa = '90'
+phoenixass = '90'
+phoenixb = '90'
+phoenixc = '90'
+phoenixc2 = '90'
+phoenixc3 = '90'
+phoenixc4 = '90'
+phoenixdal = '90'
+phoenixgu = '90'
+phoenixha = '90'
+phoenixi = '90'
+phoenixj = '90'
+phoenixs = '90'
+phoenixt = '90'
+phoenxp2 = '270'
+phozon = '90'
+phozons = '90'
+pickin = '270'
+pignewt = '270'
+pignewta = '270'
+pinbo = '90'
+pinboa = '90'
+pinbos = '90'
+pinguinos = '90'
+pinkswts = '270'
+pinkswtsa = '270'
+pinkswtsb = '270'
+pinkswtssc = '270'
+pinkswtsx = '270'
+piranha = '90'
+piranhah = '90'
+piranhao = '90'
+piratesh = '90'
+pisces = '90'
+piscesb = '90'
+pitnrun = '90'
+pitnruna = '90'
+playball = '270'
+pleiadbl = '90'
+pleiadce = '90'
+pleiads = '90'
+pleiadsb2 = '90'
+pleiadsi = '90'
+pleiadsn = '90'
+pleiadss = '90'
+plgirls = '270'
+plgirls2 = '270'
+plgirls2b = '270'
+plusalph = '270'
+plygonet = '90'
+pntnpuzl = '90'
+polaris = '270'
+polarisa = '270'
+polarisb = '270'
+polarisbr = '270'
+polariso = '270'
+pollux = '270'
+polluxa = '270'
+polluxa2 = '270'
+polluxn = '270'
+polynetw = '90'
+pootan = '90'
+pooyan = '90'
+pooyans = '90'
+popeyeman = '90'
+popflame = '90'
+popflamea = '90'
+popflameb = '90'
+popflamen = '90'
+popper = '90'
+porky = '90'
+porter = '90'
+portera = '90'
+portman = '90'
+portmanj = '90'
+portrait = '270'
+portraita = '270'
+poundfor = '270'
+poundforj = '270'
+poundforu = '270'
+ppking = '90'
+pprobe = '90'
+progolf = '270'
+progolfa = '270'
+progress = '90'
+prosoccr = '270'
+prosport = '270'
+prosporta = '270'
+protenn = '270'
+protennb = '270'
+prtytime = '90'
+psurge = '270'
+psychic5 = '270'
+psychic5j = '270'
+psyvar2 = '270'
+psyvaria = '270'
+psyvarij = '270'
+psyvarrv = '270'
+pturn = '90'
+puckman = '90'
+puckmanb = '90'
+puckmanf = '90'
+puckmanh = '90'
+puckmod = '90'
+pulsar = '270'
+puzlclub = '90'
+pwrflip = '270'
+pzlestar = '270'
+qbert = '270'
+qberta = '270'
+qbertj = '270'
+qbertqub = '270'
+qberttst = '270'
+qix = '270'
+qix2 = '270'
+qixa = '270'
+qixb = '270'
+qixo = '270'
+quaak = '90'
+quantum = '270'
+quantum1 = '270'
+quantump = '270'
+quarterb = '270'
+quarterba = '270'
+quarterbc = '270'
+quasar = '90'
+quasara = '90'
+quester = '90'
+questers = '90'
+qwak = '270'
+r2dtank = '270'
+r2dx_v33 = '270'
+r2dx_v33_r2 = '270'
+raaspec = '270'
+rackemup = '90'
+radarscp = '270'
+radarscp1 = '270'
+radarscpc = '270'
+radarzon = '90'
+radarzon1 = '90'
+radarzont = '90'
+radirgy = '270'
+radirgyo = '270'
+raflesia = '270'
+raflesiau = '270'
+raiden = '270'
+raiden2 = '270'
+raiden2dx = '270'
+raiden2e = '270'
+raiden2ea = '270'
+raiden2eg = '270'
+raiden2es = '270'
+raiden2eu = '270'
+raiden2eua = '270'
+raiden2eub = '270'
+raiden2eup = '270'
+raiden2f = '270'
+raiden2g = '270'
+raiden2hk = '270'
+raiden2i = '270'
+raiden2j = '270'
+raiden2k = '270'
+raiden2nl = '270'
+raiden2sw = '270'
+raiden2u = '270'
+raidena = '270'
+raidenb = '270'
+raidendx = '270'
+raidendxa1 = '270'
+raidendxa2 = '270'
+raidendxch = '270'
+raidendxg = '270'
+raidendxj = '270'
+raidendxja = '270'
+raidendxk = '270'
+raidendxnl = '270'
+raidendxpt = '270'
+raidendxu = '270'
+raidenk = '270'
+raidenkb = '270'
+raidenm = '270'
+raident = '270'
+raidenu = '270'
+raidenua = '270'
+raidenub = '270'
+raiders = '90'
+raidersr3 = '90'
+rallybik = '270'
+raphero = '270'
+rapheroa = '270'
+rayforce = '90'
+rayforcej = '90'
+razmataz = '90'
+rcorsair = '90'
+rdft = '270'
+rdft2 = '270'
+rdft22kc = '270'
+rdft2a = '270'
+rdft2aa = '270'
+rdft2it = '270'
+rdft2j = '270'
+rdft2ja = '270'
+rdft2jb = '270'
+rdft2jc = '270'
+rdft2s = '270'
+rdft2t = '270'
+rdft2u = '270'
+rdft2us = '270'
+rdfta = '270'
+rdftadi = '270'
+rdftam = '270'
+rdftau = '270'
+rdftauge = '270'
+rdftgb = '270'
+rdftgr = '270'
+rdftit = '270'
+rdftj = '270'
+rdftja = '270'
+rdftjb = '270'
+rdfts = '270'
+rdftu = '270'
+rdftua = '270'
+reaktor = '90'
+redalert = '270'
+redclash = '270'
+redclashs = '270'
+redclasht = '270'
+redclashta = '270'
+redfoxwp2 = '270'
+redfoxwp2a = '270'
+redhawk = '270'
+redhawke = '270'
+redhawkk = '270'
+redlin2p = '270'
+redrobin = '90'
+redufo = '270'
+redufob = '90'
+redufob2 = '90'
+regulus = '270'
+reguluso = '270'
+regulusu = '270'
+repulse = '90'
+resclunar = '270'
+rescue = '90'
+rescueb = '90'
+rescuefe = '90'
+retofinv = '90'
+retofinvb = '90'
+retofinvb1 = '90'
+retofinvb2 = '90'
+retofinvb3 = '90'
+revenger = '270'
+revngr84 = '270'
+rfjet = '270'
+rfjet2kc = '270'
+rfjeta = '270'
+rfjetj = '270'
+rfjets = '270'
+rfjetsa = '270'
+rfjett = '270'
+rfjetu = '270'
+ridleofp = '90'
+ringfgt = '90'
+ringfgt2 = '90'
+ringking = '90'
+ringking2 = '90'
+ringking3 = '90'
+ringkingw = '90'
+roadf = '90'
+roadf2 = '90'
+roadfh = '90'
+roadfu = '90'
+rockduck = '270'
+rockn = '270'
+rockn2 = '270'
+rockn3 = '270'
+rockn4 = '270'
+rockna = '270'
+rocknms = '270'
+rocnrope = '270'
+rocnropek = '270'
+rollace = '270'
+rollace2 = '90'
+rollingc = '270'
+rompers = '90'
+romperso = '90'
+ropeman = '270'
+rotaryf = '270'
+roundup = '90'
+route16 = '270'
+route16a = '270'
+route16b = '270'
+route16bl = '270'
+route16c = '270'
+route16d = '270'
+routex = '270'
+routexa = '270'
+rshark = '270'
+rugrats = '90'
+rumba = '270'
+runaways = '270'
+rundeep = '270'
+rushcrsh = '270'
+ryujin = '270'
+ryujina = '270'
+s1945 = '270'
+s1945a = '270'
+s1945bl = '270'
+s1945ii = '270'
+s1945iii = '270'
+s1945j = '270'
+s1945k = '270'
+s1945n = '270'
+s1945nj = '270'
+safarir = '90'
+safarirj = '90'
+samesame = '270'
+samesame2 = '270'
+samesamecn = '270'
+samesamenh = '270'
+samurai = '270'
+samuraia = '270'
+sandscrp = '90'
+sandscrpa = '90'
+sandscrpb = '90'
+sasuke = '90'
+satansat = '90'
+satansata = '90'
+satansatind = '90'
+saturnzi = '270'
+savanna = '90'
+savgbees = '270'
+sbagman = '270'
+sbagman2 = '270'
+sbagmani = '90'
+sbagmans = '270'
+sbasketb = '90'
+sbaskete = '90'
+sbasketg = '90'
+sbasketh = '90'
+sbdk = '270'
+sbomber = '270'
+sbombera = '270'
+sbowling = '90'
+sbrkout = '270'
+sbrkout3 = '270'
+sbrkoutc = '270'
+sbrkoutct = '270'
+sbugger = '270'
+sbuggera = '270'
+schaser = '270'
+schasera = '270'
+schaserb = '270'
+schaserc = '270'
+schasercv = '270'
+schaserm = '270'
+schick = '90'
+scobra = '90'
+scobrab = '90'
+scobrae = '90'
+scobrae2 = '90'
+scobrag = '90'
+scobraggi = '90'
+scobras = '90'
+scobrase = '90'
+scontra = '90'
+scontraa = '90'
+scontraj = '90'
+scorpion = '90'
+scorpiona = '90'
+scorpionb = '90'
+scorpionmc = '90'
+scotrsht = '90'
+scramb2 = '90'
+scramb3 = '90'
+scramblb = '90'
+scramble = '90'
+scramblebb = '90'
+scramblebf = '90'
+scramblebun = '90'
+scrambleo = '90'
+scrambler = '90'
+scrambles = '90'
+scrambles2 = '90'
+scrambp = '90'
+scramce = '90'
+scrammr = '90'
+scrampt = '90'
+scramrf = '90'
+scregg = '270'
+scyclone = '270'
+sdtennis = '270'
+sdungeon = '270'
+sdungeona = '270'
+searchar = '90'
+searcharj = '90'
+searcharu = '90'
+searthie = '270'
+searthin = '270'
+searthina = '270'
+sectrzon = '90'
+sectrzona = '90'
+sectrzont = '90'
+seicross = '90'
+seicrossa = '90'
+sengekis = '90'
+sengekisj = '90'
+senjyo = '90'
+sexyboom = '270'
+sfkick = '90'
+sfkicka = '90'
+sflush = '270'
+sformula = '90'
+sformulaa = '90'
+shaolinb = '90'
+shaolins = '90'
+sheriff = '270'
+shienryu = '270'
+shikgam2 = '270'
+shikigam = '270'
+shikigama = '270'
+shippumd = '270'
+shollow = '90'
+shollow2 = '90'
+shootbul = '90'
+shtrider = '270'
+shtridera = '270'
+shtriderb = '270'
+shuffle = '90'
+shuttlei = '270'
+sia2650 = '270'
+sianniv = '270'
+sicv = '270'
+sicv1 = '270'
+silvmil = '270'
+sindbadm = '270'
+sinistar = '270'
+sinistar2 = '270'
+sinistarp = '270'
+sinvasn = '270'
+sinvasnb = '270'
+sinvemag = '270'
+sinvzen = '270'
+sirio2 = '90'
+sisv = '270'
+sisv1 = '270'
+sisv2 = '270'
+sisv3 = '270'
+sitv = '270'
+sitv1 = '270'
+skullfng = '270'
+skullfnga = '270'
+skullfngj = '270'
+skyadvnt = '90'
+skyadvntj = '90'
+skyadvntu = '90'
+skyalert = '270'
+skyarmy = '90'
+skybase = '90'
+skybump = '90'
+skychut = '270'
+skyfox = '90'
+skylancr = '90'
+skylancre = '90'
+skylove = '270'
+skyraidr = '90'
+skyshark = '270'
+skysharka = '270'
+skysharkb = '270'
+skysmash = '270'
+skysoldr = '90'
+skysoldrbl = '90'
+sl2007 = '270'
+slapfigh = '270'
+slapfigha = '270'
+slapfighb1 = '270'
+slapfighb2 = '270'
+slapfighb3 = '270'
+slither = '270'
+slithera = '270'
+slspirit = '270'
+smooncrs = '90'
+sncwgltd = '270'
+sngkace = '270'
+sngkacea = '270'
+solarfox = '270'
+solarwar = '270'
+solfight = '270'
+sonicbom = '270'
+sonicbomd = '270'
+sonicwi = '270'
+sonofphx = '90'
+sos = '90'
+spacbat2 = '90'
+spacbatt = '90'
+spacbeam = '270'
+spaceat2 = '270'
+spaceatt = '270'
+spaceatt2k = '270'
+spaceattbp = '270'
+spacebrd = '270'
+spacecho = '270'
+spacecho2 = '270'
+spacecom = '270'
+spacecr = '90'
+spacedem = '270'
+spacefb = '270'
+spacefba = '270'
+spacefbb = '270'
+spacefbe = '270'
+spacefbe2 = '270'
+spacefbg = '270'
+spacefev = '270'
+spacefevo = '270'
+spacefevo2 = '270'
+spacefrt = '90'
+spaceg = '270'
+spaceint = '90'
+spaceintj = '90'
+spacelnc = '270'
+spacempr = '90'
+spaceod = '270'
+spaceod2 = '270'
+spaceph = '270'
+spaceplt = '90'
+spaceplta = '90'
+spacerng = '90'
+spacetrk = '270'
+spacetrkc = '270'
+spacewr3 = '270'
+spacmiss = '270'
+spactrai = '90'
+spbactn = '90'
+spbactnj = '90'
+spbactnp = '90'
+spcdrag = '90'
+spceking = '270'
+spcewarl = '270'
+spcewarla = '270'
+spcewars = '270'
+spcforc2 = '270'
+spcforce = '270'
+spcfrcii = '270'
+spcinv95 = '270'
+spcinv95u = '270'
+spcking2 = '270'
+spclaser = '270'
+spclforc = '270'
+spcmission = '90'
+spcpostn = '90'
+spctbird = '90'
+spctrek = '90'
+spcwarp = '90'
+spdcoin = '90'
+speakhlp = '270'
+speakres = '270'
+speakresb = '270'
+spec2k = '270'
+speedbal = '270'
+speedbala = '270'
+spfghmk2 = '90'
+spfghmk22 = '90'
+spiders = '270'
+spiders2 = '270'
+spiders3 = '270'
+spinkick = '90'
+spinner = '270'
+sprglbpg = '90'
+sprglobp = '90'
+springer = '270'
+sprpuzzle = '270'
+spyhunt = '90'
+spyhuntp = '90'
+spyhuntpr = '90'
+spyhuntsp = '90'
+sqbert = '270'
+sqix = '90'
+sqixb1 = '90'
+sqixb2 = '90'
+sqixr0 = '90'
+sqixr1 = '90'
+sqixu = '90'
+sraider = '270'
+srdarwin = '270'
+srdarwinj = '270'
+srdmissn = '90'
+srumbler = '270'
+srumbler2 = '270'
+srumbler3 = '270'
+ssi = '270'
+ssia = '270'
+ssib = '270'
+ssingles = '90'
+ssmissin = '270'
+ssozumo = '270'
+sspacaho = '270'
+sspaceat = '270'
+sspaceat2 = '270'
+sspaceat3 = '270'
+sspaceatc = '270'
+sspeedr = '270'
+sspiritj = '270'
+sspirits = '270'
+sspirtfc = '270'
+ssrj = '90'
+sstarbtl = '90'
+sstarcrs = '90'
+sstingry = '90'
+sstrangr = '270'
+sstrangr2 = '270'
+sstriker = '270'
+sstrikerk = '270'
+stagger1 = '270'
+starfght = '90'
+starfgmc = '90'
+starfigh = '90'
+starforc = '90'
+starforca = '90'
+starforcb = '90'
+starforce = '90'
+starjack = '270'
+starjacks = '270'
+starrkr = '270'
+starw = '270'
+starw1 = '270'
+starwarr = '270'
+step3 = '270'
+stepstag = '270'
+stera = '90'
+stg = '270'
+stinger = '90'
+stinger2 = '90'
+stmblade = '270'
+stmbladej = '270'
+storming = '270'
+storminga = '270'
+stratab = '270'
+stratab1 = '270'
+stratabs = '270'
+stratvox = '270'
+stratvoxa = '270'
+stratvoxb = '270'
+streakng = '90'
+streaknga = '90'
+strfbomb = '90'
+strfight = '90'
+strtheat = '270'
+strtheata = '270'
+stuntair = '90'
+sub = '270'
+sundance = '270'
+superabc = '90'
+superabco = '90'
+superbar = '270'
+superbik = '90'
+superbon = '90'
+superbug = '270'
+superg = '90'
+supergs = '90'
+supergx = '90'
+superinv = '270'
+superpac = '90'
+superpacm = '90'
+supertnk = '90'
+superwng = '90'
+superx = '270'
+superxm = '270'
+supinvsion = '270'
+supmodel = '90'
+suprglob = '270'
+suprheli = '90'
+suprmous = '90'
+suprridr = '90'
+survival = '90'
+swarm = '90'
+swat = '270'
+swipeout = '270'
+sxevious = '90'
+sxeviousj = '90'
+szaxxon = '90'
+tacscan = '270'
+tactcian = '90'
+tactcian2 = '90'
+tagteam = '270'
+tail2nos = '90'
+takeoff = '90'
+talbot = '270'
+tankbatt = '90'
+tankbattb = '90'
+tankbust = '90'
+tattack = '270'
+taxidriv = '90'
+tazmani2 = '90'
+tazmani3 = '90'
+tazmania = '90'
+tazmaniet = '90'
+tazzmang = '90'
+tazzmang2 = '90'
+tcobra2 = '270'
+tcobra2u = '270'
+tdfever = '90'
+tdfever2 = '90'
+tdfever2b = '90'
+tdfeverj = '90'
+tdragon = '270'
+tdragon1 = '270'
+tdragon2 = '270'
+tdragon2a = '270'
+tdragon3h = '270'
+tdragonb = '270'
+tdragonb2 = '270'
+teamqb = '270'
+teamqb2 = '270'
+teedoff = '90'
+telpacfl = '270'
+tempest = '270'
+tempest1 = '270'
+tempest1r = '270'
+tempest2 = '270'
+tempest3 = '270'
+temptube = '270'
+tenkomor = '90'
+tenkomorja = '90'
+tenspot = '270'
+terracre = '270'
+terracrea = '270'
+terracren = '270'
+terracreo = '270'
+tharrier = '270'
+tharrierb = '270'
+tharrieru = '270'
+the26thz = '90'
+thedeep = '270'
+theend = '90'
+theends = '90'
+theendss = '90'
+theglob = '270'
+theglob2 = '270'
+theglob3 = '270'
+theglobme = '90'
+theglobp = '90'
+thehand = '270'
+thehole = '90'
+thehustl = '90'
+thehustlj = '90'
+thepit = '90'
+thepitj = '90'
+thepitm = '90'
+thepitu1 = '90'
+thepitu2 = '90'
+theportr = '90'
+thndblst = '270'
+thunderl = '270'
+thunderlbl = '270'
+thunderlbl2 = '270'
+tigerh = '270'
+tigerhb1 = '270'
+tigerhb2 = '270'
+tigerhb3 = '270'
+tigerhb4 = '270'
+tigerhj = '270'
+timefgtr = '90'
+timelimt = '90'
+timeplt = '90'
+timeplta = '90'
+timepltc = '90'
+timescan = '270'
+timescan1 = '270'
+timesold = '90'
+timesold1 = '90'
+tinv2650 = '270'
+tiptop = '90'
+tisland = '270'
+titanpac = '90'
+tndrcade = '270'
+tndrcadej = '270'
+tnextspc = '90'
+tnextspc2 = '90'
+tnextspcj = '90'
+tnk3 = '270'
+tnk3b = '270'
+tnk3j = '270'
+todruaga = '90'
+todruagao = '90'
+todruagas = '90'
+tokio = '90'
+tokiob = '90'
+tokioo = '90'
+tokiou = '90'
+tokisens = '90'
+tokisensa = '90'
+tomahawk = '90'
+tomahawk1 = '90'
+toobin = '270'
+toobin1 = '270'
+toobin2 = '270'
+toobin2e = '270'
+toobine = '270'
+toobing = '270'
+topgunbl = '90'
+topgunr = '90'
+toprollr = '90'
+toratora = '90'
+toratorab = '90'
+torus = '90'
+toucheme = '90'
+touchemea = '90'
+tp84 = '90'
+tp84a = '90'
+tp84b = '90'
+tranqgun = '270'
+travrusa = '270'
+travrusab = '270'
+travrusab2 = '270'
+treahunt = '90'
+trgheart = '270'
+trghearto = '270'
+tricktrp = '90'
+trigon = '90'
+trikitri = '90'
+triplep = '90'
+triplepa = '90'
+tripool = '90'
+tripoola = '90'
+trisport = '270'
+trizeal = '270'
+tron = '90'
+tron2 = '90'
+tron3 = '90'
+tron4 = '90'
+tron5 = '90'
+tronger = '90'
+troopy = '90'
+truxton = '270'
+truxton2 = '270'
+tryout = '90'
+tsamurai = '90'
+tsamurai2 = '90'
+tsamuraih = '90'
+ttfitter = '90'
+tugboat = '90'
+tunhunt = '90'
+tunhuntc = '90'
+turbo = '270'
+turboa = '270'
+turbob = '270'
+turbobl = '270'
+turboc = '270'
+turbod = '270'
+turboe = '270'
+turbofrc = '270'
+turbofrco = '270'
+turbofrcu = '270'
+turbotag = '90'
+turpin = '90'
+turpins = '90'
+turtles = '90'
+tutankhm = '90'
+tutankhms = '90'
+twinbee = '90'
+twinbeeb = '90'
+twincobr = '270'
+twincobru = '270'
+twineag2 = '270'
+twineagl = '270'
+twinhawk = '270'
+twinhawku = '270'
+typhoon = '90'
+ultrainv = '270'
+ultrax = '270'
+ultraxg = '270'
+unclepoo = '90'
+undefeat = '270'
+uniwars = '90'
+uniwarsa = '90'
+upndown = '270'
+upndownu = '270'
+upyoural = '270'
+usclssic = '270'
+valkyrie = '90'
+valtric = '270'
+vandyke = '270'
+vandykeb = '270'
+vandykejal = '270'
+vandykejal2 = '270'
+vangrd2 = '270'
+vanguard = '90'
+vanguardc = '90'
+vanguardg = '90'
+vanguardj = '90'
+vanvan = '270'
+vanvanb = '270'
+vanvank = '270'
+vaportra = '270'
+vaportra3 = '270'
+vaportrau = '270'
+varth = '270'
+varthb = '270'
+varthb2 = '270'
+varthj = '270'
+varthjr = '270'
+varthr1 = '270'
+varthu = '270'
+vasara = '270'
+vasara2 = '270'
+vasara2a = '270'
+vastar = '90'
+vastar2 = '90'
+vastar3 = '90'
+vastar4 = '90'
+vautour = '90'
+vautourz = '90'
+vautourza = '90'
+vega = '270'
+venus = '90'
+verbena = '270'
+vfive = '270'
+victoryc = '270'
+victorycb = '270'
+victroad = '270'
+videight = '90'
+videopin = '270'
+videopina = '270'
+vimana = '270'
+vimanaj = '270'
+vimanan = '270'
+viprp1 = '270'
+viprp1h = '270'
+viprp1hk = '270'
+viprp1j = '270'
+viprp1k = '270'
+viprp1oj = '270'
+viprp1ot = '270'
+viprp1pt = '270'
+viprp1s = '270'
+viprp1t = '270'
+viprp1u = '270'
+viprp1ua = '270'
+vmetal = '90'
+vmetaln = '90'
+volfied = '270'
+volfiedj = '270'
+volfiedjo = '270'
+volfiedo = '270'
+volfiedu = '270'
+volfieduo = '270'
+vortex = '270'
+vpool = '90'
+vsgongf = '90'
+vulgus = '270'
+vulgusa = '90'
+vulgusj = '270'
+waidrive = '270'
+wallst = '90'
+wanted = '90'
+wantsega = '270'
+warcadia = '270'
+warofbug = '90'
+warofbugg = '90'
+warofbugu = '90'
+warp1 = '270'
+warp1bl = '270'
+warp1t = '90'
+warpwarp = '90'
+warpwarpr = '90'
+warpwarpr2 = '90'
+waterski = '270'
+westgun2 = '270'
+wiggie = '270'
+wingforc = '270'
+wiping = '90'
+wivernwg = '270'
+wiz = '270'
+wizt = '270'
+wizta = '270'
+wmatch = '270'
+wndrplnt = '270'
+woodpeca = '90'
+woodpeck = '90'
+worldwar = '270'
+wrestwar = '270'
+wrestwar1 = '270'
+wrestwar1d = '270'
+wrestwar2 = '270'
+wrestwar2d = '270'
+wrofaero = '270'
+wtennis = '270'
+ww3 = '270'
+wwestern = '270'
+wwestern1 = '270'
+wyvernf0 = '270'
+wyvernf0a = '270'
+wyvernwg = '270'
+wyvernwga = '270'
+x2222 = '270'
+x2222o = '270'
+xevios = '90'
+xevious = '90'
+xeviousa = '90'
+xeviousb = '90'
+xeviousc = '90'
+xiistag = '270'
+xxmissio = '90'
+yamagchi = '90'
+yamato = '90'
+yamato2 = '90'
+yankeedo = '270'
+yellowcbb = '270'
+yellowcbj = '270'
+yosakdon = '270'
+yosakdona = '270'
+youjyudn = '270'
+youma = '270'
+youma2 = '270'
+youmab = '270'
+youmab2 = '270'
+yukiwo = '270'
+zaryavos = '90'
+zarzon = '90'
+zaviga = '90'
+zavigaj = '90'
+zaxxon = '90'
+zaxxon2 = '90'
+zaxxon3 = '90'
+zaxxonb = '90'
+zaxxonj = '90'
+zerohour = '270'
+zerohoura = '270'
+zerohouri = '270'
+zerotime = '90'
+zerotimed = '90'
+zerotimemc = '90'
+zerotimeu = '90'
+zigzagb = '90'
+zigzagb2 = '90'
+zingzip = '270'
+zingzipbl = '270'
+zipzap = '90'
+zipzapa = '90'
+zoar = '270'
+zodiack = '270'
+zzyzzyxx = '90'
+zzyzzyxx2 = '90'
diff --git a/python-src/batocera-launch/resources/data/special/naomi.toml b/python-src/batocera-launch/resources/data/special/naomi.toml
new file mode 100644
index 00000000000..8967d5a03f7
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/naomi.toml
@@ -0,0 +1,25 @@
+anmlbskta = '270'
+anmlbskt = '270'
+blokpong = '270'
+ikaruga = '270'
+illvelo = '270'
+karous = '270'
+mamonoro = '270'
+psyvar2 = '270'
+quizqgd = '270'
+radirgy = '270'
+radirgyo = '270'
+shikgam2 = '270'
+sl2007 = '270'
+shorse = '270'
+starhrcl = '270'
+shors2k1 = '270'
+shorsep = '270'
+starhrsp = '270'
+shorsepr = '270'
+starhrpr = '270'
+trghearto = '270'
+trgheart = '270'
+trizeal = '270'
+undefeat = '270'
+waidrive = '270'
diff --git a/python-src/batocera-launch/resources/data/special/nds.toml b/python-src/batocera-launch/resources/data/special/nds.toml
new file mode 100644
index 00000000000..b97d787e645
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/nds.toml
@@ -0,0 +1,154 @@
+'0356 - Korogashi Puzzle Katamari Damacy (Japan)' = '90'
+'0396 - Brain Age - Train Your Brain in Minutes a Day! (USA)' = '90'
+'0823 - Hotel Dusk - Room 215 (USA)' = '90'
+'0831 - Wish Room - Tenshi no Kioku (Japan)' = '90'
+'0841 - Delbo (USA)' = '90'
+'0863 - Meteos - Disney Magic (USA)' = '90'
+"0918 - Tokimeki Memorial Girl's Side - 1st Love (Japan)" = '90'
+'1000 - Hotel Dusk - Room 215 (Europe) (En,Fr,De,Es,It)' = '90'
+'1007 - Delbo (Europe, Australia)' = '90'
+'100_Classic_Books_USA_NDS-BAHAMUT' = '90'
+'1034 - Meteos - Disney Magic (Japan)' = '90'
+'1121 - Meteos - Disney Magic (Europe) (En,Fr,De,Es,It)' = '90'
+'1129 - Planet Puzzle League (USA)' = '90'
+'1182 - Puzzle League DS (Europe) (En,Fr,De,Es,It)' = '90'
+'1343 - Brain Age 2 - More Training in Minutes a Day! (USA) (En,Fr,Es)' = '90'
+'13_Sai_no_Hello_Work_DS_JPN_NDS-iND' = '90'
+'1873 - Left Brain, Right Brain - Use Both Hands, Train Both Sides (USA)' = '90'
+'1880 - Hello Kitty no Oshare Party - Sanrio Character Zukan DS (Japan)' = '90'
+"2032 - Tokimeki Memorial Girl's Side - 2nd Season (Japan)" = '90'
+'2163 - Ninja Gaiden - Dragon Sword (Japan)' = '90'
+'2189 - Ninja Gaiden - Dragon Sword (USA)' = '90'
+'2331 - Shokugyou Taiken Adventure - 13-sai no Hello Work DS (Japan)' = '90'
+'2380 - Guitar Hero - On Tour (USA) (En,Fr)' = '90'
+'2387 - Ninja Gaiden - Dragon Sword (Europe) (En,Fr,De,Es,It)' = '90'
+'2420 - Nanashi no Geemu (Japan)' = '90'
+'2476 - Guitar Hero - On Tour (Europe) (Fr,De,Es,It)' = '90'
+'2500 - Guitar Hero - On Tour (United Kingdom)' = '90'
+'2517 - Rhythm Tengoku Gold (Japan)' = '90'
+'2550 - Guitar Hero - On Tour (Korea) [b]' = '90'
+'2581 - Sigma Harmonics (Japan)' = '90'
+'2582 - Ninja Gaiden - Dragon Sword (Korea) [b]' = '90'
+'2702 - My Secret World by Imagine (Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da)' = '90'
+'2754 - Guitar Hero - On Tour (Japan)' = '90'
+'2897 - Guitar Hero - On Tour - Decades (Europe) (Fr,De,Es,It)' = '90'
+'2917 - My Dress-Up (Europe) (En,Fr,De,Es,It,Sv,No,Da,Fi)' = '90'
+'2919 - My Secret Diary (Europe) (En,Fr,De,Es,It,Sv,No,Da,Fi)' = '90'
+'2939 - Guitar Hero - On Tour - Decades (United Kingdom)' = '90'
+'2944 - Guitar Hero - On Tour - Decades (USA) (En,Fr)' = '90'
+'3363 - My Secret World by Imagine (USA) (En,Fr,Es,Pt)' = '90'
+'3380 - Hotel Duskui Bimil (Korea) [b]' = '90'
+'3564 - Guitar Hero - On Tour - Decades (Korea) [b]' = '90'
+'3588 - Rhythm Heaven (USA)' = '90'
+'3601 - Akai Ito Destiny DS (Japan)' = '90'
+'3699 - Rhythm Paradise (Europe) (En,Fr,De)' = '90'
+"3724 - Tokimeki Memorial Girl's Side - 1st Love Plus (Japan) [b]" = '90'
+'3822 - Rhythm Paradise (Europe) (En,Es,It)' = '90'
+'3921 - Guitar Hero - On Tour - Modern Hits (Europe) (Fr,De,Es,It)' = '90'
+"4039 - Gokujou!! Mecha Mote Iinchou - Girls 'Mote Kawa' Box (Japan)" = '90'
+'4125 - My Dress-Up (USA)' = '90'
+'4133 - Nanashi no Geemu Me (Japan)' = '90'
+'4136 - Go! Go! Cosmo Cops! (Europe) (En,Fr,De,Es,It)' = '90'
+'4152 - Love Plus (Japan)' = '90'
+'4591 - Style Savvy (USA)' = '90'
+'4654 - Last Window - Mayonaka no Yakusoku (Japan)' = '90'
+'4876 - Again - Interactive Crime Novel (USA)' = '90'
+'4930 - Guitar Hero - On Tour - Modern Hits (United Kingdom)' = '90'
+'5014 - 100 Classic Books (USA)' = '90'
+"5035 - Tokimeki Memorial Girl's Side - 3rd Story (Japan)" = '90'
+'5171 - Brain Age - Train Your Brain in Minutes a Day! (USA) (Rev 1)' = '90'
+'5212 - Last Window - The Secret of Cape West (Europe) (En,Fr,De,Es,It)' = '90'
+'6390 - My Secret Diary (USA)' = '90'
+'6470 - Winnie the Pooh - Kuma no Puu-san - 100 Acre no Mori no Cooking Book (Japan)' = '90'
+'842 - Imagine - Fashion Designer - New York (USA) (En,Fr,Es,Pt)' = '90'
+'907 - Guitar Hero - On Tour - Modern Hits (USA) (En,Fr)' = '90'
+'Again_CRACKED_USA_NDS-SUXXORS' = '90'
+'Again_USA_NDS-BAHAMUT' = '90'
+'Akai_Ito_Destiny_DS_JPN_NDS-iND' = '90'
+'Brain_Age_2_Demo_USA_NDS-SirVG' = '90'
+'Brain_Age_2_USA_NDS-MR0' = '90'
+'Brain_Age_Train_Your_Brain_in_Minutes_a_Day_v1.01_USA_NDS-PLAYTHIS' = '90'
+'Brain_Age_USA_NDS-MODE7' = '90'
+'Brain_Age_USA_NDS-TRM' = '90'
+'Go_Go_Cosmo_Cops_EUR_MULTi5_NDS-BAHAMUT' = '90'
+'Go_Go_Cosmo_Cops_EUR_MULTi5_PROPER_NDS-iND' = '90'
+'Gokujou_Mechamote_Iinchou_Girls_Motekawa_Box_JPN_NDS-Caravan' = '90'
+'Guitar.Hero.On.Tour.KOR.NDS-CoolPoint' = '90'
+'Guitar_Hero_-_On_Tour_Decades_DEMO_USA_DIRFIX_NDS-jd' = '90'
+'Guitar_Hero_-_On_Tour_Decades_USA_NDS-jd' = '90'
+'GUITAR_HERO_DS_EUR_UK_NDS-DiPLODOCUS' = '90'
+'Guitar_Hero_On_Tour_Decades_DEMO_EUR_NDS-jd' = '90'
+'Guitar_Hero_On_Tour_Decades_EUR_MULTi4_CLEAN_NDS-COCKSLAP' = '90'
+'Guitar_Hero_On_Tour_Decades_EUR_MULTi4_NDS-EXiMiUS' = '90'
+'Guitar_Hero_On_Tour_Decades_EUR_READNFO_NDS-DiPLODOCUS' = '90'
+'Guitar_Hero_On_Tour_Decades_KOR_NDS-NEREiD' = '90'
+'Guitar_Hero_On_Tour_Decades_USA_NDS-GUARDiAN' = '90'
+'Guitar_Hero_On_Tour_DEMO_EUR_NDS-iND' = '90'
+'Guitar_Hero_On_Tour_DEMO_READNFO_EUR_NDS-iND' = '90'
+'Guitar_Hero_On_Tour_DEMO_USA_NDS-iND' = '90'
+'Guitar_Hero_On_Tour_Demo_Version_USA_iNTERNAL_NDS-jd' = '90'
+'Guitar_Hero_On_Tour_EUR_MULTi4_NDS-DSRP' = '90'
+'Guitar_Hero_On_Tour_EUR_MULTi4_NDS-iND' = '90'
+'Guitar_Hero_On_Tour_JPN_NDS-iND' = '90'
+'Guitar_Hero_On_Tour_Modern_Hits_EUR_MULTi4_NDS-BAHAMUT' = '90'
+'Guitar_Hero_On_Tour_Modern_Hits_READNFO_USA_DEMO_NDS-iND' = '90'
+'Guitar_Hero_On_Tour_Modern_Hits_USA_NDS-BAHAMUT' = '90'
+'Guitar_Hero_On_Tour_USA_READNFO_NDS-XPA' = '90'
+'Hello_Kitty_no_Oshare_Party_Sanrio_Character_Zukan_DS_JPN_NDS-6rz' = '90'
+'Hotel.Dusk.Room.KOR.NDS-CoolPoint' = '90'
+'Hotel_Dusk_Room_215_EUR_NDS-SUPREMACY' = '90'
+'Hotel_Dusk_Room_215_USA_INTERNAL_NDS-XPA' = '90'
+'Hotel_Dusk_Room_215_USA_NDS-WRG' = '90'
+'Imagine_Fashion_Designer_New_York_USA_NDS-XPA' = '90'
+'Last_Window_Mayonaka_no_Yakusoku_JPN_NDS-STORM' = '90'
+'Last_Window_The_Secret_of_Cape_West_EUR_CRACK_NDS-SUXXORS' = '90'
+'Last_Window_The_Secret_of_Cape_West_EUR_MULTi5_NDS-EXiMiUS' = '90'
+'Left_Brain_Right_Brain_USA_NDS-SirVG' = '90'
+'Love_Plus_JPN_NDS-iND' = '90'
+'Meteos_Disney_Magic_EUR_NDS' = '90'
+'Meteos_Disney_Magic_JPN_NDS-Caravan' = '90'
+'Meteos_Disney_Magic_USA_NDS-XPA' = '90'
+'My_Dress_Up_EUR_NDS-XPA' = '90'
+'My_Dress_Up_USA_NDS-BAHAMUT' = '90'
+'My_Secret_Diary_EUR_NDS-XPA' = '90'
+'My_Secret_Diary_USA_NDS-EXiMiUS' = '90'
+'My_Secret_World_by_Imagine_EUR_MULTI10_NDS-SQUiRE' = '90'
+'My_Secret_World_By_Imagine_USA_NDS-SirVG' = '90'
+'My_Secret_World_EUR_CLEAN_NDS-WetNWild' = '90'
+'Nanashi_no_Game_JPN_NDS-iND' = '90'
+'Ninja.Gaiden.Dragon.Sword.KOR.NDS-Coolpoint' = '90'
+'Ninja_Gaiden_Dragon_Sword_EUR_MULTi5_NDS-EXiMiUS' = '90'
+'Ninja_Gaiden_Dragon_Sword_JPN_NDS-Caravan' = '90'
+'Ninja_Gaiden_Dragon_Sword_USA_NDS-XPA' = '90'
+'Planet_Puzzle_League_Demo_USA_NDS-SirVG' = '90'
+'Planet_Puzzle_League_USA_NDS-XPA' = '90'
+'Puzzle_League_DS_EUR_NDS-XPA' = '90'
+'Rhythm Heaven (Touchless)' = '90'
+'Rhythm_Heaven_USA_NDS-XPA' = '90'
+'Rhythm_Paradise_DEMO_EUR_NDS-iND' = '90'
+'Rhythm_Paradise_EUR_MULTi3_NDS-EXiMiUS' = '90'
+'Rhythm_Paradise_EUR_MULTi3_READNFO_NDS-BAHAMUT' = '90'
+'Rhythm_Tengoku_Gold_JAP_NDS-CNBS' = '90'
+'Rhythm_Tengoku_Gold_JPN_NDS-DSRP' = '90'
+'Sigma_Harmonics_JPN_NDS-iND' = '90'
+'Sophies_Freunde_Meine_Geheimnisse_GER_NDS-DNB' = '90'
+'Stock Exchange Kabutore Next (Japan)' = '90'
+'Style_Savvy_DEMO_USA_NDS-TDCC' = '90'
+'Style_Savvy_USA_NDS-XPA' = '90'
+'Winnie_The_Pooh_100_Acre_No_Mori_No_Cooking_Book_JAP_NDS-PUSSYCAT' = '90'
+'Wish_Room_Tenshi_no_Kioku_JPN_NDS-WRG' = '90'
+'x115 - Planet Puzzle League (USA) (Demo) (Kiosk)' = '90'
+'x117 - Brain Age 2 - More Training in Minutes a Day! (USA) (Demo) (Kiosk)' = '90'
+'x138 - Guitar Hero - On Tour (Europe) (Fr,De,Es,It) (Demo) (Kiosk)' = '90'
+'x140 - Guitar Hero - On Tour - Decades (Europe) (Fr,De,Es,It) (Demo) (Kiosk)' = '90'
+'x143 - Guitar Hero - On Tour - Decades (USA) (En,Fr) (Demo) (Kiosk)' = '90'
+'x146 - Rhythm Paradise (Europe) (En,Fr,De) (Demo) (Kiosk)' = '90'
+'x155 - Guitar Hero - On Tour (United Kingdom) (Demo) (Kiosk)' = '90'
+'x158 - Guitar Hero - On Tour - Modern Hits (USA) (En,Fr) (Demo) (Kiosk)' = '90'
+'x192 - Guitar Hero - On Tour (USA) (Demo) (Kiosk)' = '90'
+'x196 - Style Savvy (USA) (Demo) (Kiosk)' = '90'
+"z082 - Tokimeki Memorial Girl's Side - 1st Love (Japan) (Rev 1)" = '90'
+"z119 - Tokimeki Memorial Girl's Side - 2nd Season (Japan) (Rev 1)" = '90'
+'z170 - Rhythm Heaven (USA) (Demo) (Kiosk)' = '90'
+'z175 - Ninja Gaiden - Dragon Sword (USA) (Demo) (Kiosk)' = '90'
+'z189 - Puzzle League DS (Europe) (En,Fr,De,Es,It) (Rev 1)' = '90'
diff --git a/python-src/batocera-launch/resources/data/special/vectrex.toml b/python-src/batocera-launch/resources/data/special/vectrex.toml
new file mode 100644
index 00000000000..b880e994423
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/vectrex.toml
@@ -0,0 +1,55 @@
+'2D Narrow Escape (Hombrew)' = '2dnarrow'
+'3D Mine Storm (USA)' = '3dminest'
+'AnimAction - Advanced Animation (USA)' = 'animactn'
+'Armor..Attack (World)' = 'armora'
+'Art Master (USA)' = 'artmstr'
+'Bedlam (USA, Europe)' = 'bedlam'
+'Berzerk (World)' = 'berzerk'
+'Blitz! - Action Football (USA, Europe) (0F11CE0C)' = 'blitz'
+'Blitz! - Action Football (USA, Europe) (881B27C5)' = 'blitz'
+'Blitz! - Action Football (USA, Europe)' = 'blitz'
+'Clean Sweep (World)' = 'cleanswp'
+'Cosmic Chasm (World)' = 'cchasm'
+'Dark Tower (USA) (Proto)' = 'darktowr'
+'Dark Tower (World) (Proto)' = 'darktowr'
+'Engine Analyzer (USA) (Proto)' = 'enginean'
+'Flipper Pinball (World)' = 'fpinball'
+'Fortress of Narzod (USA, Europe)' = 'fnarzod'
+'Heads-Up - Action Soccer (USA)' = 'headsup'
+'HyperChase - Auto Race (World)' = 'hyprchas'
+'Mail Plane (USA) (Proto) (05838962)' = 'mailplan'
+'Mail Plane (USA) (Proto) (DA1AC0DB)' = 'mailplan'
+'Melody Master - Music Composition and Entertainment (USA)' = 'melody'
+'Mine Storm (World)' = 'minestrm'
+'Mine Storm II (USA) (Rev 2)' = 'minestr2'
+'Mine Storm II (USA)' = 'minestr2'
+'Mr. Boston Clean Sweep (Europe)' = 'mrboston'
+'Patriots (Homebrew)' = 'patriots'
+'Patriots3 (Homebrew)' = 'patriot3'
+"Pitcher's Duel (USA) (Proto)" = 'ptchduel'
+'Polar Rescue (USA) (Beta)' = 'polarres'
+'Polar Rescue (USA)' = 'polarres'
+'Pole Position (USA) (A00ED3D6)' = 'polepos'
+'Pole Position (USA) (C10F37D8)' = 'polepos'
+'Pole Position (USA)' = 'polepos'
+'Rip Off (World)' = 'ripoff'
+'Scramble (USA, Europe)' = 'scramble'
+'Sledge 3D (USA) (Sample)' = 'rocksled'
+'Solar Quest (World)' = 'solarq'
+'Space Wars (World)' = 'spacewar'
+'Spike (USA, Europe)' = 'spike'
+'Spike Goes Skiing (Homebrew)' = 'spikeski'
+'Spin ball (USA)' = 'spinball'
+'Spinball (USA)' = 'spinball'
+'Star Castle (USA)' = 'starcas'
+'Star Ship (Europe)' = 'starship'
+'Star Trek - The Motion Picture (USA)' = 'startrek'
+'StarHawk (World)' = 'starhawk'
+'Test Cartridge (USA) (Rev 4) (Proto)' = 'testrev4'
+'Tour De France (USA) (Proto)' = 'tourfrnc'
+'Vectrace (Homebrew)' = 'vectrace'
+'Vectrex Frogger (Homebrew)' = 'frogger'
+'Vectrexians (Homebrew)' = 'vectrian'
+'WebWarp (Europe)' = 'webwarp'
+'WebWars (USA)' = 'webwars'
+'Wormhole (Homebrew)' = 'wormhole'
diff --git a/python-src/batocera-launch/resources/data/special/wswan.toml b/python-src/batocera-launch/resources/data/special/wswan.toml
new file mode 100644
index 00000000000..418e322e09a
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/wswan.toml
@@ -0,0 +1,21 @@
+'beat mania for WonderSwan (Japan)' = '90'
+'Beatmania for WonderSwan (Japan)' = '90'
+'Crazy Climber (Japan)' = '90'
+"D's Garage 21 Koubo Game - Tane o Maku Tori (Japan)" = '90'
+'From TV Animation One Piece - Mezase Kaizoku Ou! (Japan)' = '90'
+'GunPey (Japan)' = '90'
+'Magical Drop for WonderSwan (Japan)' = '90'
+'Meitantei Conan - Majutsushi no Chousenjou! (Japan)' = '90'
+'Mingle Magnet (Japan) (En,Ja) (Rev 1)' = '90'
+'Mingle Magnet (Japan)' = '90'
+'Mobile Suit Gundam MSVS (Japan)' = '90'
+'Puzzle Bobble (Japan)' = '90'
+"Rainbow Islands - Putty's Party (Japan)" = '90'
+'Ring Infinity (Japan)' = '90'
+'Sennou Millennium (Japan)' = '90'
+'Slither Link (Japan)' = '90'
+'Tare Panda no GunPey (Japan)' = '90'
+'Terrors (Japan)' = '90'
+'Tetsujin 28 Gou (Japan)' = '90'
+'Tokyo Majin Gakuen - Fuju Houroku (Japan)' = '90'
+'Uzumaki - Denshi Kaiki Hen (Japan) (Rev 4)' = '90'
diff --git a/python-src/batocera-launch/resources/data/special/wswanc.toml b/python-src/batocera-launch/resources/data/special/wswanc.toml
new file mode 100644
index 00000000000..6cf92210a9f
--- /dev/null
+++ b/python-src/batocera-launch/resources/data/special/wswanc.toml
@@ -0,0 +1,14 @@
+'Another Heaven - Memory of those Days (Japan)' = '90'
+'Digital Monster Card Game - Ver. WonderSwan Color (Japan)' = '90'
+'Digital Monster Card Game - Ver. WonderSwan Color (Japan) (Rev 2)' = '90'
+'Digital Monster Card Game - Ver. WonderSwan Color (Japan) [T-En by Kaiisen & Wazzpinator v1.1] [i]' = '90'
+'GunPey EX (Japan)' = '90'
+'Judgement Silversword - Rebirth Edition (Japan) (Rev 4321)' = '90'
+'Judgement Silversword - Rebirth Edition (Japan) (Rev 5C21)' = '90'
+'Kurupara! (Japan) (Rev 1)' = '90'
+'Last Alive (Japan)' = '90'
+'Mikeneko Holmes - Ghost Panic (Japan)' = '90'
+'Sorobang (Japan) (Rev 1)' = '90'
+'Terrors 2 (Japan)' = '90'
+'Tetris (Japan)' = '90'
+'Wonder Classic (Japan)' = '90'
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-a3gen2.yml b/python-src/batocera-launch/resources/defaults/config-a3gen2.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-a3gen2.yml
rename to python-src/batocera-launch/resources/defaults/config-a3gen2.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2711.yml b/python-src/batocera-launch/resources/defaults/config-bcm2711.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2711.yml
rename to python-src/batocera-launch/resources/defaults/config-bcm2711.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2712.yml b/python-src/batocera-launch/resources/defaults/config-bcm2712.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2712.yml
rename to python-src/batocera-launch/resources/defaults/config-bcm2712.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2835.yml b/python-src/batocera-launch/resources/defaults/config-bcm2835.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2835.yml
rename to python-src/batocera-launch/resources/defaults/config-bcm2835.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2836.yml b/python-src/batocera-launch/resources/defaults/config-bcm2836.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2836.yml
rename to python-src/batocera-launch/resources/defaults/config-bcm2836.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2837.yml b/python-src/batocera-launch/resources/defaults/config-bcm2837.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-bcm2837.yml
rename to python-src/batocera-launch/resources/defaults/config-bcm2837.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-h3.yml b/python-src/batocera-launch/resources/defaults/config-h3.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-h3.yml
rename to python-src/batocera-launch/resources/defaults/config-h3.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-h5.yml b/python-src/batocera-launch/resources/defaults/config-h5.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-h5.yml
rename to python-src/batocera-launch/resources/defaults/config-h5.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-h6.yml b/python-src/batocera-launch/resources/defaults/config-h6.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-h6.yml
rename to python-src/batocera-launch/resources/defaults/config-h6.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-h616.yml b/python-src/batocera-launch/resources/defaults/config-h616.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-h616.yml
rename to python-src/batocera-launch/resources/defaults/config-h616.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-h700.yml b/python-src/batocera-launch/resources/defaults/config-h700.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-h700.yml
rename to python-src/batocera-launch/resources/defaults/config-h700.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-jh7110.yml b/python-src/batocera-launch/resources/defaults/config-jh7110.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-jh7110.yml
rename to python-src/batocera-launch/resources/defaults/config-jh7110.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-odroidxu4.yml b/python-src/batocera-launch/resources/defaults/config-odroidxu4.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-odroidxu4.yml
rename to python-src/batocera-launch/resources/defaults/config-odroidxu4.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-qcs6490.yml b/python-src/batocera-launch/resources/defaults/config-qcs6490.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-qcs6490.yml
rename to python-src/batocera-launch/resources/defaults/config-qcs6490.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3128.yml b/python-src/batocera-launch/resources/defaults/config-rk3128.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3128.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3128.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3288.yml b/python-src/batocera-launch/resources/defaults/config-rk3288.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3288.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3288.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3326.yml b/python-src/batocera-launch/resources/defaults/config-rk3326.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3326.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3326.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3328.yml b/python-src/batocera-launch/resources/defaults/config-rk3328.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3328.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3328.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3399.yml b/python-src/batocera-launch/resources/defaults/config-rk3399.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3399.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3399.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3568.yml b/python-src/batocera-launch/resources/defaults/config-rk3568.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3568.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3568.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3576.yml b/python-src/batocera-launch/resources/defaults/config-rk3576.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3576.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3576.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3588-mainline.yml b/python-src/batocera-launch/resources/defaults/config-rk3588-mainline.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3588-mainline.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3588-mainline.yml
diff --git a/python-src/batocera-launch/resources/defaults/config-rk3588-sdio.yml b/python-src/batocera-launch/resources/defaults/config-rk3588-sdio.yml
new file mode 120000
index 00000000000..da204112088
--- /dev/null
+++ b/python-src/batocera-launch/resources/defaults/config-rk3588-sdio.yml
@@ -0,0 +1 @@
+config-rk3588.yml
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3588.yml b/python-src/batocera-launch/resources/defaults/config-rk3588.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-rk3588.yml
rename to python-src/batocera-launch/resources/defaults/config-rk3588.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-s812.yml b/python-src/batocera-launch/resources/defaults/config-s812.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-s812.yml
rename to python-src/batocera-launch/resources/defaults/config-s812.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-s905.yml b/python-src/batocera-launch/resources/defaults/config-s905.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-s905.yml
rename to python-src/batocera-launch/resources/defaults/config-s905.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-s905gen2.yml b/python-src/batocera-launch/resources/defaults/config-s905gen2.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-s905gen2.yml
rename to python-src/batocera-launch/resources/defaults/config-s905gen2.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-s905gen3.yml b/python-src/batocera-launch/resources/defaults/config-s905gen3.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-s905gen3.yml
rename to python-src/batocera-launch/resources/defaults/config-s905gen3.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-s912.yml b/python-src/batocera-launch/resources/defaults/config-s912.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-s912.yml
rename to python-src/batocera-launch/resources/defaults/config-s912.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-s922x.yml b/python-src/batocera-launch/resources/defaults/config-s922x.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-s922x.yml
rename to python-src/batocera-launch/resources/defaults/config-s922x.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-s9gen4.yml b/python-src/batocera-launch/resources/defaults/config-s9gen4.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-s9gen4.yml
rename to python-src/batocera-launch/resources/defaults/config-s9gen4.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-sdm845.yml b/python-src/batocera-launch/resources/defaults/config-sdm845.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-sdm845.yml
rename to python-src/batocera-launch/resources/defaults/config-sdm845.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-sm6115.yml b/python-src/batocera-launch/resources/defaults/config-sm6115.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-sm6115.yml
rename to python-src/batocera-launch/resources/defaults/config-sm6115.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-sm8250.yml b/python-src/batocera-launch/resources/defaults/config-sm8250.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-sm8250.yml
rename to python-src/batocera-launch/resources/defaults/config-sm8250.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-sm8550.yml b/python-src/batocera-launch/resources/defaults/config-sm8550.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-sm8550.yml
rename to python-src/batocera-launch/resources/defaults/config-sm8550.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-sm8750.yml b/python-src/batocera-launch/resources/defaults/config-sm8750.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-sm8750.yml
rename to python-src/batocera-launch/resources/defaults/config-sm8750.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-t527.yml b/python-src/batocera-launch/resources/defaults/config-t527.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-t527.yml
rename to python-src/batocera-launch/resources/defaults/config-t527.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-x86.yml b/python-src/batocera-launch/resources/defaults/config-x86.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-x86.yml
rename to python-src/batocera-launch/resources/defaults/config-x86.yml
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults-x86_64.yml b/python-src/batocera-launch/resources/defaults/config-x86_64.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults-x86_64.yml
rename to python-src/batocera-launch/resources/defaults/config-x86_64.yml
diff --git a/python-src/batocera-launch/resources/defaults/config-zen3.yml b/python-src/batocera-launch/resources/defaults/config-zen3.yml
new file mode 120000
index 00000000000..ad0ad7f1347
--- /dev/null
+++ b/python-src/batocera-launch/resources/defaults/config-zen3.yml
@@ -0,0 +1 @@
+config-x86_64.yml
\ No newline at end of file
diff --git a/package/batocera/core/batocera-configgen/configs/configgen-defaults.yml b/python-src/batocera-launch/resources/defaults/config.yml
similarity index 100%
rename from package/batocera/core/batocera-configgen/configs/configgen-defaults.yml
rename to python-src/batocera-launch/resources/defaults/config.yml
diff --git a/package/batocera/core/batocera-configgen/hotkeys.keys b/python-src/batocera-launch/resources/hotkeys.keys
similarity index 100%
rename from package/batocera/core/batocera-configgen/hotkeys.keys
rename to python-src/batocera-launch/resources/hotkeys.keys
diff --git a/package/batocera/core/batocera-configgen/scripts/nvidia-workaround.sh b/python-src/batocera-launch/resources/scripts/nvidia-workaround.sh
similarity index 100%
rename from package/batocera/core/batocera-configgen/scripts/nvidia-workaround.sh
rename to python-src/batocera-launch/resources/scripts/nvidia-workaround.sh
diff --git a/package/batocera/core/batocera-configgen/scripts/powermode_launch_hooks.sh b/python-src/batocera-launch/resources/scripts/powermode_launch_hooks.sh
similarity index 100%
rename from package/batocera/core/batocera-configgen/scripts/powermode_launch_hooks.sh
rename to python-src/batocera-launch/resources/scripts/powermode_launch_hooks.sh
diff --git a/package/batocera/core/batocera-configgen/scripts/tdp_hooks.sh b/python-src/batocera-launch/resources/scripts/tdp_hooks.sh
similarity index 98%
rename from package/batocera/core/batocera-configgen/scripts/tdp_hooks.sh
rename to python-src/batocera-launch/resources/scripts/tdp_hooks.sh
index 8da2d7e07e7..72e615e226d 100644
--- a/package/batocera/core/batocera-configgen/scripts/tdp_hooks.sh
+++ b/python-src/batocera-launch/resources/scripts/tdp_hooks.sh
@@ -14,7 +14,7 @@
# YOU MUST KEEP THIS HEADER AS IT IS
#
-# this configgen script leverages the S93amdtdp init.d and
+# this batocera-launch script leverages the S93amdtdp init.d and
# the batocera-amd-tdp scripts to allow adjustments to TDP
# of supported AMD CPUs. this can improve performance
# but also improve battery life.
diff --git a/python-src/batocera-launch/tests/conftest.py b/python-src/batocera-launch/tests/conftest.py
new file mode 100644
index 00000000000..3a8cfa02b9d
--- /dev/null
+++ b/python-src/batocera-launch/tests/conftest.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+import uvloop
+from pyfakefs import helpers
+from pyfakefs.fake_filesystem import FakeFilesystem, OSType
+from pyfakefs.fake_filesystem_unittest import Patcher
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator, Mapping
+ from types import ModuleType
+
+ from pytest_asyncio.plugin import LoopFactory
+
+
+def pytest_asyncio_loop_factories(config: pytest.Config, item: pytest.Item) -> Mapping[str, LoopFactory]:
+ return {
+ 'uvloop': uvloop.new_event_loop,
+ }
+
+
+@pytest.fixture
+def fs_modules_to_reload() -> list[ModuleType] | None:
+ return
+
+
+@pytest.fixture
+def fs(fs_modules_to_reload: list[ModuleType] | None, monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeFilesystem]:
+ with monkeypatch.context() as mp:
+ # delete these so our fake filesystem does not inherit the temporary directory
+ # of the machine running the tests
+ mp.delenv('TMP', raising=False)
+ mp.delenv('TMPDIR', raising=False)
+ mp.delenv('TEMP', raising=False)
+
+ # batocera runs as root
+ helpers.set_uid(0)
+
+ with Patcher(
+ additional_skip_names=[
+ 'syrupy.utils',
+ 'syrupy.extensions.amber.serializer',
+ 'syrupy.extensions.image',
+ 'syrupy.extensions.single_file',
+ ],
+ modules_to_reload=fs_modules_to_reload,
+ allow_root_user=True,
+ ) as patcher:
+ patcher.fs.os = OSType.LINUX # pyright: ignore
+ yield patcher.fs # pyright: ignore
+
+ helpers.reset_ids()
diff --git a/python-src/batocera-launch/tests/test_build_engine.py b/python-src/batocera-launch/tests/test_build_engine.py
new file mode 100644
index 00000000000..ad6dccaea06
--- /dev/null
+++ b/python-src/batocera-launch/tests/test_build_engine.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from batocera_launch.config.build_engine import parse_build_engine_args
+from batocera_launch.exceptions import InvalidConfiguration
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+pytestmark = pytest.mark.usefixtures('fs')
+
+_ROM_DIR = Path('/userdata/roms/duke')
+
+
+def _write_rom(content: str, *, name: str = 'game.eduke32') -> Path:
+ _ROM_DIR.mkdir(parents=True, exist_ok=True)
+ rom_path = _ROM_DIR / name
+ rom_path.write_text(content)
+ return rom_path
+
+
+class TestParseBuildEngineArgs:
+ def test_empty_script_returns_empty_list(self) -> None:
+ rom = _write_rom('')
+
+ assert parse_build_engine_args(rom) == []
+
+ def test_skips_blank_lines_and_comments(self) -> None:
+ rom = _write_rom(
+ '\n'.join(
+ [
+ '',
+ '# comment',
+ '// also a comment',
+ ' # indented comment',
+ 'FILE=duke3d.grp',
+ ' ',
+ ]
+ ),
+ )
+
+ assert parse_build_engine_args(rom) == ['-gamegrp', 'duke3d.grp']
+
+ def test_parses_all_known_keys(self) -> None:
+ rom = _write_rom(
+ '\n'.join(
+ [
+ 'DIR=addons',
+ 'FILE=duke3d.grp',
+ 'FILE+=mod.zip',
+ 'CON=GAME.CON',
+ 'CON+=extra.con',
+ 'DEF=duke.def',
+ 'DEF+=extra.def',
+ 'MAP=e1l1.map',
+ ]
+ ),
+ )
+
+ assert parse_build_engine_args(rom) == [
+ '-j',
+ 'addons',
+ '-gamegrp',
+ 'duke3d.grp',
+ '-g',
+ 'mod.zip',
+ '-x',
+ 'GAME.CON',
+ '-mx',
+ 'extra.con',
+ '-h',
+ 'duke.def',
+ '-mh',
+ 'extra.def',
+ '-map',
+ 'e1l1.map',
+ ]
+
+ def test_keys_are_case_insensitive_and_whitespace_trimmed(self) -> None:
+ rom = _write_rom(' file = duke3d.grp \n')
+
+ assert parse_build_engine_args(rom) == ['-gamegrp', 'duke3d.grp']
+
+ def test_leading_slash_resolves_path_relative_to_rom_parent(self) -> None:
+ _ROM_DIR.mkdir(parents=True, exist_ok=True)
+ (_ROM_DIR / 'duke3d.grp').write_text('grp')
+ (_ROM_DIR / 'addons').mkdir()
+ rom = _write_rom('FILE=/duke3d.grp\nDIR=/addons\n')
+
+ assert parse_build_engine_args(rom) == [
+ '-gamegrp',
+ _ROM_DIR / 'duke3d.grp',
+ '-j',
+ _ROM_DIR / 'addons',
+ ]
+
+ def test_missing_physical_path_raises(self) -> None:
+ rom = _write_rom('FILE=/missing.grp\n')
+
+ with pytest.raises(InvalidConfiguration, match='does not exist') as exc_info:
+ parse_build_engine_args(rom)
+
+ assert '1 error(s) found' in str(exc_info.value)
+ assert 'line 1|' in str(exc_info.value)
+
+ def test_allows_multiple_non_only_one_keys(self) -> None:
+ rom = _write_rom('FILE+=a.zip\nFILE+=b.zip\nDIR=one\nDIR=two\n')
+
+ assert parse_build_engine_args(rom) == ['-g', 'a.zip', '-g', 'b.zip', '-j', 'one', '-j', 'two']
+
+ def test_rejects_duplicate_only_one_key_in_script(self) -> None:
+ rom = _write_rom('FILE=a.grp\nFILE=b.grp\n')
+
+ with pytest.raises(InvalidConfiguration, match="found another 'FILE'") as exc_info:
+ parse_build_engine_args(rom)
+
+ assert '1 error(s) found' in str(exc_info.value)
+ assert 'line 2|' in str(exc_info.value)
+
+ def test_rejects_only_one_key_already_in_existing(self) -> None:
+ rom = _write_rom('FILE=duke3d.grp\n')
+ existing: Iterable[str | Path] = ['eduke32', '-cfg', 'settings.cfg', '-gamegrp']
+
+ with pytest.raises(InvalidConfiguration, match="found another 'FILE'"):
+ parse_build_engine_args(rom, existing=existing)
+
+ def test_existing_paths_are_ignored_for_seen_options(self) -> None:
+ rom = _write_rom('FILE=duke3d.grp\n')
+ existing: Iterable[str | Path] = [Path('/-gamegrp')]
+
+ assert parse_build_engine_args(rom, existing=existing) == ['-gamegrp', 'duke3d.grp']
+
+ @pytest.mark.parametrize(
+ ('content', 'error'),
+ [
+ ('FILE duke3d.grp', "KEY and/or VAL is empty; are you missing a '='?"),
+ ('FILE=', "KEY and/or VAL is empty; are you missing a '='?"),
+ ('=duke3d.grp', "KEY and/or VAL is empty; are you missing a '='?"),
+ ('FILE=a=b', "found another '=', but there should only be one"),
+ ('UNKNOWN=value', "KEY 'UNKNOWN' is not valid"),
+ ],
+ )
+ def test_parse_errors(self, content: str, error: str) -> None:
+ rom = _write_rom(f'{content}\n')
+
+ with pytest.raises(InvalidConfiguration, match=error):
+ parse_build_engine_args(rom)
+
+ def test_collects_multiple_errors(self) -> None:
+ rom = _write_rom(
+ '\n'.join(
+ [
+ 'BAD',
+ 'UNKNOWN=x',
+ 'FILE=/missing.grp',
+ ]
+ ),
+ )
+
+ with pytest.raises(InvalidConfiguration) as exc_info:
+ parse_build_engine_args(rom)
+
+ message = str(exc_info.value)
+ assert '3 error(s) found' in message
+ assert 'line 1|' in message
+ assert 'line 2|' in message
+ assert 'line 3|' in message
+ assert "missing a '='" in message
+ assert "KEY 'UNKNOWN' is not valid" in message
+ assert 'does not exist' in message
diff --git a/python-src/batocera-launch/tests/test_decoration_id.py b/python-src/batocera-launch/tests/test_decoration_id.py
new file mode 100644
index 00000000000..47e7c3c7467
--- /dev/null
+++ b/python-src/batocera-launch/tests/test_decoration_id.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from batocera_launch.config.decoration_id import get_decoration_id
+from batocera_launch.paths import LAUNCH_DATA_DIR
+
+if TYPE_CHECKING:
+ from pyfakefs.fake_filesystem import FakeFilesystem
+
+pytestmark = pytest.mark.usefixtures('fs')
+
+_REAL_SPECIAL_DIR = Path(__file__).resolve().parents[1] / 'resources' / 'data' / 'special'
+
+
+@pytest.fixture(autouse=True)
+def special_data(fs: FakeFilesystem) -> None:
+ fs.add_real_directory(_REAL_SPECIAL_DIR, target_path=LAUNCH_DATA_DIR / 'special') # pyright: ignore
+
+
+class TestGetDecorationId:
+ def test_unknown_system_returns_zero(self) -> None:
+ assert get_decoration_id('nosuchsystem', 'any-rom') == '0'
+
+ def test_known_system_unknown_rom_returns_zero(self) -> None:
+ assert get_decoration_id('dice', 'not-a-game') == '0'
+
+ def test_matches_rom_from_special_toml(self) -> None:
+ assert get_decoration_id('dice', 'breakout') == '270'
+ assert get_decoration_id('dice', 'pinpong') == '270'
+
+ def test_match_is_case_insensitive(self) -> None:
+ assert get_decoration_id('dice', 'BREAKOUT') == '270'
+ assert (
+ get_decoration_id(
+ 'lynx',
+ 'gauntlet - the third encounter (usa, europe)',
+ )
+ == '270'
+ )
+
+ def test_quoted_keys_with_spaces(self) -> None:
+ assert get_decoration_id('lynx', 'Klax (USA, Europe)') == '270'
+ assert get_decoration_id('lynx', 'Lexis (USA)') == '90'
+
+ def test_underscore_rom_ids(self) -> None:
+ assert get_decoration_id('3ds', 'New_Love_Plus_JPN_3DS-Kirin') == '90'
+ assert get_decoration_id('3ds', 'new_love_plus_plus_jpn_3ds-kirin') == '90'
diff --git a/python-src/batocera-launch/tests/test_defaults.py b/python-src/batocera-launch/tests/test_defaults.py
new file mode 100644
index 00000000000..b09d68b12dc
--- /dev/null
+++ b/python-src/batocera-launch/tests/test_defaults.py
@@ -0,0 +1,257 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from batocera_launch.config.defaults import load_defaults, load_system_defaults
+from batocera_launch.paths import DEFAULTS_DIR
+
+if TYPE_CHECKING:
+ from pyfakefs.fake_filesystem import FakeFilesystem
+
+pytestmark = pytest.mark.usefixtures('fs')
+
+_REAL_DEFAULTS_DIR = Path(__file__).resolve().parents[1] / 'resources' / 'defaults'
+
+
+def _write_yaml(path: Path, content: str) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ return path
+
+
+class TestLoadDefaults:
+ def test_returns_base_default(self) -> None:
+ base = _write_yaml(
+ Path('/tmp/config.yml'),
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+ options:
+ smooth: true
+""",
+ )
+ arch = Path('/tmp/config-arch.yml')
+
+ assert load_defaults('unknown', base, arch) == {
+ 'emulator': 'libretro',
+ 'core': 'fceumm',
+ 'options': {'smooth': True},
+ }
+
+ def test_merges_arch_default_over_base_default(self) -> None:
+ base = _write_yaml(
+ Path('/tmp/config.yml'),
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+ options:
+ smooth: true
+ videomode: default
+""",
+ )
+ arch = _write_yaml(
+ Path('/tmp/config-arch.yml'),
+ """\
+default:
+ options:
+ smooth: false
+ hud_support: true
+""",
+ )
+
+ assert load_defaults('unknown', base, arch) == {
+ 'emulator': 'libretro',
+ 'core': 'fceumm',
+ 'options': {
+ 'smooth': False,
+ 'videomode': 'default',
+ 'hud_support': True,
+ },
+ }
+
+ def test_merges_system_over_defaults(self) -> None:
+ base = _write_yaml(
+ Path('/tmp/config.yml'),
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+ options:
+ smooth: true
+nes:
+ core: nestopia
+ options:
+ rewind: true
+""",
+ )
+ arch = Path('/tmp/config-arch.yml')
+
+ assert load_defaults('nes', base, arch) == {
+ 'emulator': 'libretro',
+ 'core': 'nestopia',
+ 'options': {
+ 'smooth': True,
+ 'rewind': True,
+ },
+ }
+
+ def test_arch_system_overrides_system(self) -> None:
+ base = _write_yaml(
+ Path('/tmp/config.yml'),
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+nes:
+ core: nestopia
+ options:
+ videomode: default
+""",
+ )
+ arch = _write_yaml(
+ Path('/tmp/config-arch.yml'),
+ """\
+nes:
+ emulator: mednafen
+ options:
+ videomode: max-1920x1080
+""",
+ )
+
+ assert load_defaults('nes', base, arch) == {
+ 'emulator': 'mednafen',
+ 'core': 'nestopia',
+ 'options': {'videomode': 'max-1920x1080'},
+ }
+
+ def test_missing_arch_file_is_ignored(self) -> None:
+ base = _write_yaml(
+ Path('/tmp/config.yml'),
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+""",
+ )
+
+ assert load_defaults('nes', base, Path('/tmp/missing-arch.yml')) == {
+ 'emulator': 'libretro',
+ 'core': 'fceumm',
+ }
+
+ def test_empty_arch_file_is_ignored(self) -> None:
+ base = _write_yaml(
+ Path('/tmp/config.yml'),
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+""",
+ )
+ arch = _write_yaml(Path('/tmp/config-arch.yml'), '')
+
+ assert load_defaults('nes', base, arch) == {
+ 'emulator': 'libretro',
+ 'core': 'fceumm',
+ }
+
+ def test_no_default_section_starts_empty(self) -> None:
+ base = _write_yaml(
+ Path('/tmp/config.yml'),
+ """\
+nes:
+ emulator: libretro
+ core: fceumm
+""",
+ )
+ arch = Path('/tmp/config-arch.yml')
+
+ assert load_defaults('nes', base, arch) == {
+ 'emulator': 'libretro',
+ 'core': 'fceumm',
+ }
+
+
+class TestLoadSystemDefaults:
+ def test_flattens_options_into_top_level(self) -> None:
+ _write_yaml(
+ DEFAULTS_DIR / 'config.yml',
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+ options:
+ smooth: true
+ videomode: default
+nes:
+ core: nestopia
+ options:
+ rewind: true
+""",
+ )
+
+ assert load_system_defaults('nes') == {
+ 'emulator': 'libretro',
+ 'core': 'nestopia',
+ 'smooth': True,
+ 'videomode': 'default',
+ 'rewind': True,
+ }
+
+ def test_merges_arch_overrides_when_present(self) -> None:
+ _write_yaml(
+ DEFAULTS_DIR / 'config.yml',
+ """\
+default:
+ emulator: libretro
+ core: fceumm
+ options:
+ smooth: true
+n64:
+ emulator: mupen64plus
+ core: glide64mk2
+""",
+ )
+ _write_yaml(
+ DEFAULTS_DIR / 'config-arch.yml',
+ """\
+default:
+ options:
+ hud_support: true
+n64:
+ options:
+ videomode: max-1920x1080
+""",
+ )
+
+ assert load_system_defaults('n64') == {
+ 'emulator': 'mupen64plus',
+ 'core': 'glide64mk2',
+ 'smooth': True,
+ 'hud_support': True,
+ 'videomode': 'max-1920x1080',
+ }
+
+ def test_real_defaults_resources(self, fs: FakeFilesystem) -> None:
+ fs.add_real_directory(_REAL_DEFAULTS_DIR, target_path=DEFAULTS_DIR) # pyright: ignore
+ fs.add_real_file( # pyright: ignore
+ _REAL_DEFAULTS_DIR / 'config-x86_64.yml',
+ target_path=DEFAULTS_DIR / 'config-arch.yml',
+ )
+
+ nes = load_system_defaults('nes')
+ assert nes['emulator'] == 'libretro'
+ assert nes['core'] == 'fceumm'
+ assert nes['hud_support'] is True
+ assert nes['smooth'] is True
+
+ n64 = load_system_defaults('n64')
+ assert n64['emulator'] == 'mupen64plus'
+ assert n64['core'] == 'glide64mk2'
+ assert n64['videomode'] == 'max-1920x1080'
+ assert n64['video_frame_delay_auto'] is True
diff --git a/python-src/python-batocera-common/Config.in b/python-src/python-batocera-common/Config.in
index 4504b818a01..77920f21936 100644
--- a/python-src/python-batocera-common/Config.in
+++ b/python-src/python-batocera-common/Config.in
@@ -4,5 +4,6 @@ config BR2_PACKAGE_PYTHON_BATOCERA_COMMON
select BR2_PACKAGE_PYTHON_PYYAML
select BR2_PACKAGE_PYTHON_RUAMEL_YAML
select BR2_PACKAGE_PYTHON_TYPING_EXTENSIONS
+ select BR2_PACKAGE_SDL3
help
Common python functionality for Batocera
diff --git a/python-src/python-batocera-common/batocera_common/asyncio.py b/python-src/python-batocera-common/batocera_common/asyncio.py
new file mode 100644
index 00000000000..16f5f333252
--- /dev/null
+++ b/python-src/python-batocera-common/batocera_common/asyncio.py
@@ -0,0 +1,321 @@
+from __future__ import annotations
+
+import asyncio
+import locale
+import logging
+import os
+import sys
+import threading
+from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
+from dataclasses import dataclass
+from subprocess import CalledProcessError
+from typing import TYPE_CHECKING, Any, Concatenate, Final, Literal, cast, overload
+
+import aiohttp
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
+ from pathlib import Path
+ from subprocess import _ENV
+
+_logger: Final = logging.getLogger(__name__)
+
+
+def run_in_new_uvloop[T](coro: Awaitable[T], /) -> T:
+ """Run a coroutine in a fresh thread with its own uvloop."""
+ import uvloop
+
+ result: list[T] = []
+
+ def runner() -> None:
+ loop = uvloop.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+ result.append(loop.run_until_complete(coro))
+ finally:
+ loop.close()
+
+ t = threading.Thread(target=runner)
+ t.start()
+ t.join()
+ return result[0]
+
+
+@dataclass(slots=True)
+class AsyncCompletedProcess[T]:
+ returncode: int
+ stdout: T
+ stderr: T
+
+
+def _text_encoding() -> str:
+ if sys.flags.utf8_mode:
+ return 'utf-8'
+
+ return locale.getencoding()
+
+
+def _decode(data: bytes, encoding: str, /) -> str:
+ text = data.decode(encoding)
+ return text.replace('\r\n', '\n').replace('\r', '\n')
+
+
+def env_to_fspath(env: _ENV | None, /) -> dict[str, str | bytes] | dict[bytes, str | bytes] | None:
+ if env is None:
+ return None
+
+ return {cast('Any', key): os.fspath(value) for key, value in env.items()}
+
+
+@overload
+async def run(
+ cmd: str | Path,
+ /,
+ *args: str | Path,
+ check: bool = ...,
+ shell: bool = ...,
+ text: bool = ...,
+ capture_output: Literal[False],
+ env: _ENV | None = None,
+) -> AsyncCompletedProcess[None]: ...
+
+
+@overload
+async def run(
+ cmd: str | Path,
+ /,
+ *args: str | Path,
+ check: bool = ...,
+ shell: Literal[False] = False,
+ text: Literal[False] = False,
+ capture_output: Literal[True] = True,
+ env: _ENV | None = None,
+) -> AsyncCompletedProcess[bytes]: ...
+
+
+@overload
+async def run(
+ cmd: str | Path,
+ /,
+ *args: str | Path,
+ check: bool = ...,
+ shell: Literal[False] = False,
+ text: Literal[True],
+ capture_output: Literal[True] = True,
+ env: _ENV | None = None,
+) -> AsyncCompletedProcess[str]: ...
+
+
+@overload
+async def run(
+ cmd: str,
+ /,
+ *args: str,
+ check: bool = ...,
+ text: Literal[False] = False,
+ shell: Literal[True],
+ capture_output: Literal[True] = True,
+ env: _ENV | None = None,
+) -> AsyncCompletedProcess[bytes]: ...
+
+
+@overload
+async def run(
+ cmd: str,
+ /,
+ *args: str,
+ check: bool = ...,
+ shell: Literal[True],
+ text: Literal[True],
+ capture_output: Literal[True] = True,
+ env: _ENV | None = None,
+) -> AsyncCompletedProcess[str]: ...
+
+
+async def run(
+ cmd: Any,
+ /,
+ *args: Any,
+ shell: bool = False,
+ check: bool = False,
+ text: bool = False,
+ capture_output: bool = True,
+ env: _ENV | None = None,
+) -> AsyncCompletedProcess[Any]:
+ io = asyncio.subprocess.PIPE if capture_output else asyncio.subprocess.DEVNULL
+
+ if shell:
+ proc = await asyncio.create_subprocess_shell(
+ ' '.join([cmd, *args]),
+ stdout=io,
+ stderr=io,
+ env=env_to_fspath(env),
+ )
+ else:
+ proc = await asyncio.create_subprocess_exec(
+ cmd,
+ *args,
+ stdout=io,
+ stderr=io,
+ env=env_to_fspath(env),
+ )
+
+ if capture_output:
+ (out, err) = await proc.communicate()
+ else:
+ out = err = None
+
+ return_code = await proc.wait()
+
+ if text and out is not None and err is not None:
+ encoding = _text_encoding()
+ out = _decode(out, encoding)
+ err = _decode(err, encoding)
+
+ if check and return_code:
+ raise CalledProcessError(return_code, cmd, output=out, stderr=err)
+
+ return AsyncCompletedProcess(returncode=return_code, stdout=out, stderr=err)
+
+
+@overload
+async def group_tasks() -> tuple[()]: ...
+@overload
+async def group_tasks[T1](
+ coro1: asyncio._CoroutineLike[T1],
+ /,
+) -> tuple[T1]: ...
+@overload
+async def group_tasks[T1, T2](
+ coro1: asyncio._CoroutineLike[T1],
+ coro2: asyncio._CoroutineLike[T2],
+ /,
+) -> tuple[T1, T2]: ...
+@overload
+async def group_tasks[T1, T2, T3](
+ coro1: asyncio._CoroutineLike[T1],
+ coro2: asyncio._CoroutineLike[T2],
+ coro3: asyncio._CoroutineLike[T3],
+ /,
+) -> tuple[T1, T2, T3]: ...
+@overload
+async def group_tasks[T1, T2, T3, T4](
+ coro1: asyncio._CoroutineLike[T1],
+ coro2: asyncio._CoroutineLike[T2],
+ coro3: asyncio._CoroutineLike[T3],
+ coro4: asyncio._CoroutineLike[T4],
+ /,
+) -> tuple[T1, T2, T3, T4]: ...
+
+
+async def group_tasks(*coros: asyncio._CoroutineLike[Any]) -> tuple[Any, ...]:
+ """Run multiple coroutines concurrently and return their results as a tuple.
+
+ Unlike `asyncio.gather`, this function will raise the first exception that occurs
+ in any of the coroutines, and will cancel all other coroutines.
+ """
+
+ async with asyncio.TaskGroup() as task_group:
+ tasks = [task_group.create_task(coro) for coro in coros]
+
+ return tuple(task.result() for task in tasks)
+
+
+@overload
+def parallel() -> AbstractAsyncContextManager[tuple[()]]: ...
+@overload
+def parallel[T1](
+ manager1: AbstractAsyncContextManager[T1],
+ /,
+) -> AbstractAsyncContextManager[tuple[T1]]: ...
+@overload
+def parallel[T1, T2](
+ manager1: AbstractAsyncContextManager[T1],
+ manager2: AbstractAsyncContextManager[T2],
+ /,
+) -> AbstractAsyncContextManager[tuple[T1, T2]]: ...
+@overload
+def parallel[T1, T2, T3](
+ manager1: AbstractAsyncContextManager[T1],
+ manager2: AbstractAsyncContextManager[T2],
+ manager3: AbstractAsyncContextManager[T3],
+ /,
+) -> AbstractAsyncContextManager[tuple[T1, T2, T3]]: ...
+@overload
+def parallel[T1, T2, T3, T4](
+ manager1: AbstractAsyncContextManager[T1],
+ manager2: AbstractAsyncContextManager[T2],
+ manager3: AbstractAsyncContextManager[T3],
+ manager4: AbstractAsyncContextManager[T4],
+ /,
+) -> AbstractAsyncContextManager[tuple[T1, T2, T3, T4]]: ...
+
+
+@asynccontextmanager
+async def parallel(*managers: AbstractAsyncContextManager[Any]) -> AsyncGenerator[tuple[Any, ...]]:
+ """Enter multiple async context managers concurrently and yield their results as a tuple.
+
+ If any of the context managers fails to enter, the others will be exited in reverse order
+ of the order they were passed to the function.
+ """
+
+ results: list[Any] = [None] * len(managers)
+ entered = [False] * len(managers)
+
+ async with AsyncExitStack() as stack:
+
+ async def _enter(index: int, manager: AbstractAsyncContextManager[Any]) -> None:
+ results[index] = await manager.__aenter__()
+ entered[index] = True
+
+ try:
+ async with asyncio.TaskGroup() as task_group:
+ for index, manager in enumerate(managers):
+ task_group.create_task(_enter(index, manager))
+ finally:
+ for index, manager in enumerate(managers):
+ if entered[index]:
+ # Register the exit of the entered context manager with the stack, so that it
+ # will be called in reverse order of entry, even if an exception occurs in one
+ # of the context managers.
+ stack.push_async_exit(manager)
+
+ # If any of the context managers fails to enters, the following line
+ # never executes
+ yield tuple(results)
+
+
+async def create_ready_task[**P, R](
+ callable: Callable[Concatenate[asyncio.Event, P], Coroutine[Any, Any, R]],
+ /,
+ *args: P.args,
+ **kwargs: P.kwargs,
+) -> asyncio.Task[R]:
+ """Start a coroutine as a background task and wait until it signals readiness.
+
+ The callable receives an `asyncio.Event` as its first argument (followed by
+ any additional `*args` / `**kwargs`). It must call `ready.set()` once its
+ initialization is complete. This function returns only after that signal,
+ with the task still running in the background.
+ """
+
+ ready = asyncio.Event()
+ task = asyncio.create_task(callable(ready, *args, **kwargs))
+
+ await ready.wait()
+
+ return task
+
+
+async def is_connected_to_internet() -> bool:
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1)) as session:
+ try:
+ async with session.head('https://one.one.one.one'):
+ return True
+ except aiohttp.ClientError, TimeoutError:
+ try:
+ async with session.head('https://dns.google'):
+ return True
+ except aiohttp.ClientError, TimeoutError:
+ _logger.error('Not connected to the internet')
+ return False
diff --git a/python-src/python-batocera-common/batocera_common/dataclasses.py b/python-src/python-batocera-common/batocera_common/dataclasses.py
new file mode 100644
index 00000000000..39766495c92
--- /dev/null
+++ b/python-src/python-batocera-common/batocera_common/dataclasses.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from dataclasses import Field, dataclass as _stdlib_dataclass, field
+from typing import TYPE_CHECKING, Any, Self, dataclass_transform, overload
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+class cached_property[TValue]:
+ __slots__ = ('__doc__', 'func', 'slot_descriptor')
+
+ func: Callable[[Any], TValue]
+ slot_descriptor: Any | None
+
+ def __init__(self, func: Callable[[Any], TValue]) -> None:
+ self.func = func
+ self.slot_descriptor = None
+ self.__doc__ = func.__doc__
+
+ @overload
+ def __get__(self, instance: None, owner: type[object]) -> Self: ...
+
+ @overload
+ def __get__(self, instance: object, owner: type[object]) -> TValue: ...
+
+ def __get__(self, instance: object | None, owner: type[object]) -> Self | TValue:
+ if instance is None:
+ return self
+
+ if self.slot_descriptor is None:
+ raise TypeError('cached_property must be used with batocera_common.dataclasses.cached_dataclass')
+
+ try:
+ return self.slot_descriptor.__get__(instance, owner)
+ except AttributeError:
+ value = self.func(instance)
+ self.slot_descriptor.__set__(instance, value)
+ return value
+
+ def __delete__(self, instance: object) -> None:
+ if self.slot_descriptor is None:
+ raise TypeError('cached_property must be used with batocera_common.dataclasses.cached_dataclass')
+
+ try:
+ self.slot_descriptor.__delete__(instance)
+ except AttributeError:
+ pass
+
+
+@overload
+def cached_dataclass[T](
+ cls: type[T],
+ /,
+ *,
+ init: bool = True,
+ repr: bool = True,
+ eq: bool = True,
+ order: bool = False,
+ unsafe_hash: bool = False,
+ frozen: bool = False,
+ match_args: bool = True,
+ kw_only: bool = False,
+ weakref_slot: bool = False,
+) -> type[T]: ...
+
+
+@overload
+def cached_dataclass[T](
+ cls: None = None,
+ /,
+ *,
+ init: bool = True,
+ repr: bool = True,
+ eq: bool = True,
+ order: bool = False,
+ unsafe_hash: bool = False,
+ frozen: bool = False,
+ match_args: bool = True,
+ kw_only: bool = False,
+ weakref_slot: bool = False,
+) -> Callable[[type[T]], type[T]]: ...
+
+
+@dataclass_transform(field_specifiers=(field, Field))
+def cached_dataclass[T](
+ cls: type[T] | None = None,
+ /,
+ *,
+ init: bool = True,
+ repr: bool = True,
+ eq: bool = True,
+ order: bool = False,
+ unsafe_hash: bool = False,
+ frozen: bool = False,
+ match_args: bool = True,
+ kw_only: bool = False,
+ weakref_slot: bool = False,
+) -> type[T] | Callable[[type[T]], type[T]]:
+ def wrap(cls: type[T]) -> type[T]:
+ cached_props: dict[str, cached_property[Any]] = {}
+
+ for key, value in list(vars(cls).items()):
+ if isinstance(value, cached_property):
+ cached_props[key] = value
+ setattr(cls, key, field(init=False, repr=False, compare=False))
+
+ if cached_props:
+ for key in cached_props:
+ cls.__annotations__[key] = object
+
+ new_cls = _stdlib_dataclass(
+ cls,
+ init=init,
+ repr=repr,
+ eq=eq,
+ order=order,
+ unsafe_hash=unsafe_hash,
+ frozen=frozen,
+ match_args=match_args,
+ kw_only=kw_only,
+ slots=True,
+ weakref_slot=weakref_slot,
+ )
+
+ for key, cached_prop in cached_props.items():
+ descriptor = new_cls.__dict__.get(key)
+
+ if descriptor is None:
+ # If we're overriding a parent property, no slot is created for it
+ # in the subclass, so we need to get the descriptor from the parent class
+ descriptor = getattr(new_cls, key)
+
+ if isinstance(descriptor, cached_property):
+ # If the descriptor is a cached property from the parent class, we have
+ # to get the slot descriptor from the parent class, since the subclass
+ # doesn't have one
+ descriptor = descriptor.slot_descriptor
+
+ cached_prop.slot_descriptor = descriptor
+ setattr(new_cls, key, cached_prop)
+
+ return new_cls
+
+ if cls is None:
+ return wrap
+
+ return wrap(cls)
diff --git a/python-src/python-batocera-common/batocera_common/dict.py b/python-src/python-batocera-common/batocera_common/dict.py
new file mode 100644
index 00000000000..e0a679567cd
--- /dev/null
+++ b/python-src/python-batocera-common/batocera_common/dict.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+
+# adapted from https://gist.github.com/angstwad/bf22d1822c38a92ec0a9
+def merge(destination: dict[str, Any], source: Mapping[str, Any]) -> None:
+ """Recursive dict merge. Inspired by :meth:``dict.update()``, instead of
+ updating only top-level keys, dict_merge recurses down into dicts nested
+ to an arbitrary depth, updating keys. The ``merge_dct`` is merged into
+ ``dct``.
+ :param destination: dict onto which the merge is executed
+ :param source: dict merged into destination
+ :return: None
+ """
+ for key, value in source.items():
+ if key in destination and isinstance(destination[key], dict) and isinstance(value, Mapping):
+ merge(destination[key], value) # pyright: ignore[reportUnknownArgumentType]
+ else:
+ destination[key] = value
diff --git a/python-src/python-batocera-common/batocera_common/key_value_config.py b/python-src/python-batocera-common/batocera_common/key_value_config.py
new file mode 100644
index 00000000000..5d2ae9af045
--- /dev/null
+++ b/python-src/python-batocera-common/batocera_common/key_value_config.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import logging
+import re
+from configparser import UNNAMED_SECTION
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Final, overload
+
+from .configparser import CaseSensitiveConfigParser
+
+if TYPE_CHECKING:
+ from _typeshed import StrPath
+ from collections.abc import Iterator
+
+_logger: Final = logging.getLogger(__name__)
+_sub_re: Final = re.compile(r'[^A-Za-z0-9-\.]+')
+
+
+def _protect_string(string: str, /) -> str:
+ return _sub_re.sub('_', string)
+
+
+def _section_re(section: str, /) -> re.Pattern[str]:
+ return re.compile(rf'^{_protect_string(section)}\.(.+)')
+
+
+@dataclass(slots=True)
+class KeyValueConfig:
+ separator: str = field(default='')
+
+ __config: CaseSensitiveConfigParser = field(init=False)
+
+ def __post_init__(self) -> None:
+ self.__config = CaseSensitiveConfigParser(interpolation=None, strict=False, allow_unnamed_section=True)
+ self.__config.add_section(UNNAMED_SECTION)
+
+ def read(self, path: StrPath, /, *, encoding: str | None = 'latin1') -> None:
+ try:
+ self.__config.read_string(Path(path).read_text(encoding=encoding), source=str(path))
+ except OSError as e:
+ _logger.error(str(e))
+
+ def write(self, path: StrPath, /, *, encoding: str | None = None) -> None:
+ with Path(path).open('w', encoding=encoding) as fp:
+ try:
+ for key, value in self.__config.items(UNNAMED_SECTION):
+ fp.write(f'{key}{self.separator}={self.separator}{value!s}\n')
+ except Exception:
+ # PSX Mednafen writes beetle_psx_hw_cpu_freq_scale = "100%(native)"
+ # Python 2.7 is EOL and ConfigParser 2.7 takes "%(" as a won't fix error
+ _logger.error('Wrong value detected (after % char maybe?), ignoring.')
+
+ def __getitem__(self, key: str) -> str:
+ return self.__config.get(UNNAMED_SECTION, key)
+
+ def __setitem__(self, key: str, value: str) -> None:
+ self.__config.set(UNNAMED_SECTION, key, value)
+
+ def __delitem__(self, key: str) -> None:
+ self.__config.remove_option(UNNAMED_SECTION, key)
+
+ def __contains__(self, key: str) -> bool:
+ return self.__config.has_option(UNNAMED_SECTION, key)
+
+ @overload
+ def get(self, key: str, /) -> str | None: ...
+
+ @overload
+ def get[T](self, key: str, /, default: T) -> str | T: ...
+
+ def get[T](self, key: str, /, default: T | None = None) -> str | T | None:
+ return self.__config.get(UNNAMED_SECTION, key, fallback=default)
+
+ def section(self, section: str, /, *, keep_defaults: bool = False) -> dict[str, str]:
+ return dict(self.section_items(section, keep_defaults=keep_defaults))
+
+ def section_items(self, section: str, /, *, keep_defaults: bool = False) -> Iterator[tuple[str, str]]:
+ section_re = _section_re(section)
+
+ for key, value in self.__config.items(UNNAMED_SECTION):
+ if m := section_re.match(_protect_string(key)):
+ if not keep_defaults and value in ['', 'default', 'auto']:
+ continue
+
+ yield m.group(1), value
+
+ def remove_all_starting_with(self, prefix: str, /) -> None:
+ for key in self.__config[UNNAMED_SECTION]:
+ if key.startswith(prefix):
+ self.__config.remove_option(UNNAMED_SECTION, key)
+
+ def remove_section(self, section: str, /) -> None:
+ self.remove_all_starting_with(f'{section}.')
diff --git a/python-src/python-batocera-common/batocera_common/math.py b/python-src/python-batocera-common/batocera_common/math.py
new file mode 100644
index 00000000000..b32c26ce774
--- /dev/null
+++ b/python-src/python-batocera-common/batocera_common/math.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from _typeshed import SupportsRichComparison
+
+
+def clamp[T: SupportsRichComparison](value: T, lower: T, upper: T, /) -> T:
+ """Clamp a value between a lower and upper bound."""
+
+ return min(max(lower, value), upper)
diff --git a/python-src/python-batocera-common/batocera_common/sdl3.py b/python-src/python-batocera-common/batocera_common/sdl3.py
new file mode 100644
index 00000000000..a30afd24048
--- /dev/null
+++ b/python-src/python-batocera-common/batocera_common/sdl3.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import ctypes
+import ctypes.util
+import os
+from dataclasses import InitVar, dataclass
+from enum import IntFlag
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Final, Never, NewType
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+
+# A small ctypes wrapper for SDL3, providing access to some of its functions and constants.
+# pysdl3 is poorly typed and a very thin wrapper over ctypes. This module provides a more
+# Pythonic interface to SDL3, with proper type hints and enums.
+
+_sdl3 = ctypes.CDLL(ctypes.util.find_library('SDL3') or 'libSDL3.so')
+
+
+@dataclass(slots=True, frozen=True)
+class _function[**P, R]:
+ func: ctypes._NamedFuncPointer
+
+ argtypes: InitVar[Sequence[type[ctypes._CDataType]]]
+ restype: InitVar[type[ctypes._CDataType] | Callable[[int], Any] | None]
+
+ def __post_init__(
+ self,
+ argtypes: Sequence[type[ctypes._CDataType]],
+ restype: type[ctypes._CDataType] | Callable[[int], Any] | None,
+ ) -> None:
+ self.func.argtypes = argtypes
+ self.func.restype = restype
+
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
+ return self.func(*args, **kwargs)
+
+
+_SDL_InitFlags: Final = ctypes.c_uint32
+_SDL_JoystickID: Final = ctypes.c_uint32
+
+
+class _SDL_GUID(ctypes.Structure):
+ _fields_ = [
+ ('data', ctypes.c_uint8 * 16),
+ ]
+
+
+_SDL_free: Final = _function[[Any], None](_sdl3.SDL_free, argtypes=[ctypes.c_void_p], restype=None)
+_SDL_GetError: Final = _function[[], bytes | None](_sdl3.SDL_GetError, argtypes=[], restype=ctypes.c_char_p)
+_SDL_ClearError: Final = _function[[], bool](_sdl3.SDL_ClearError, argtypes=[], restype=ctypes.c_bool)
+_SDL_Init: Final = _function[[int], bool](_sdl3.SDL_Init, argtypes=[_SDL_InitFlags], restype=ctypes.c_bool)
+_SDL_WasInit: Final = _function[[int], int](_sdl3.SDL_WasInit, argtypes=[_SDL_InitFlags], restype=_SDL_InitFlags)
+_SDL_QuitSubSystem: Final = _function[[int], None](_sdl3.SDL_QuitSubSystem, argtypes=[_SDL_InitFlags], restype=None)
+_SDL_Quit: Final = _function[[], None](_sdl3.SDL_Quit, argtypes=[], restype=None)
+_SDL_UpdateJoysticks: Final = _function[[], None](_sdl3.SDL_UpdateJoysticks, argtypes=[], restype=None)
+_SDL_GetJoysticks: Final = _function[
+ ['ctypes._CArgObject'], # pyright: ignore[reportGeneralTypeIssues]
+ 'ctypes._Pointer[_SDL_JoystickID]',
+](
+ _sdl3.SDL_GetJoysticks,
+ argtypes=[ctypes.POINTER(ctypes.c_int)],
+ restype=ctypes.POINTER(_SDL_JoystickID),
+)
+_SDL_GUIDToString: Final = _function[[_SDL_GUID, ctypes.Array[ctypes.c_char], int], None](
+ _sdl3.SDL_GUIDToString, argtypes=[_SDL_GUID, ctypes.c_char_p, ctypes.c_int], restype=None
+)
+_SDL_GetJoystickGUIDForID: Final = _function[[int], _SDL_GUID](
+ _sdl3.SDL_GetJoystickGUIDForID, argtypes=[_SDL_JoystickID], restype=_SDL_GUID
+)
+_SDL_GetJoystickPathForID: Final = _function[[int], bytes | None](
+ _sdl3.SDL_GetJoystickPathForID, argtypes=[_SDL_JoystickID], restype=ctypes.c_char_p
+)
+
+
+class SDLError(Exception):
+ """Exception raised for SDL errors."""
+
+ def __init__(self, message: bytes):
+ super().__init__(message.decode('utf-8'))
+
+
+def _raise_sdl_error() -> Never:
+ error = _SDL_GetError() or b'Unknown SDL error'
+ _SDL_ClearError()
+ raise SDLError(error)
+
+
+class InitFlags(IntFlag):
+ NONE = 0
+ AUDIO = 0x00000010
+ VIDEO = 0x00000020
+ JOYSTICK = 0x00000200
+ HAPTIC = 0x00001000
+ GAMEPAD = 0x00002000
+ EVENTS = 0x00004000
+ SENSOR = 0x00008000
+ CAMERA = 0x00010000
+
+
+JoystickID = NewType('JoystickID', int)
+
+
+def was_init(flags: InitFlags, /) -> InitFlags:
+ result = _SDL_WasInit(int(flags))
+ return InitFlags(result)
+
+
+def init(flags: InitFlags, /) -> None:
+ if not _SDL_Init(int(flags)):
+ _raise_sdl_error()
+
+
+def quit_subsystem(flags: InitFlags, /) -> None:
+ _SDL_QuitSubSystem(int(flags))
+
+
+def quit() -> None:
+ _SDL_Quit()
+
+
+def update_joysticks() -> None:
+ _SDL_UpdateJoysticks()
+
+
+def get_joystick_ids() -> list[JoystickID]:
+ count = ctypes.c_int()
+ joysticks_ptr = _SDL_GetJoysticks(ctypes.byref(count))
+
+ if not joysticks_ptr:
+ _raise_sdl_error()
+
+ try:
+ return [JoystickID(joystick_id) for joystick_id in joysticks_ptr[: count.value]]
+ finally:
+ _SDL_free(joysticks_ptr)
+
+
+def get_joystick_guid(joystick_id: JoystickID, /) -> str:
+ guid_struct = _SDL_GetJoystickGUIDForID(int(joystick_id))
+ buffer = (ctypes.c_char * 33)() # 32 characters + null terminator
+ _SDL_GUIDToString(guid_struct, buffer, 33)
+
+ return buffer.value.decode('ascii')
+
+
+def get_joystick_path(joystick_id: JoystickID, /) -> Path:
+ path_ptr = _SDL_GetJoystickPathForID(int(joystick_id))
+
+ if path_ptr is None:
+ _raise_sdl_error()
+
+ return Path(os.fsdecode(path_ptr))
diff --git a/python-src/python-batocera-common/batocera_common/vulkan.py b/python-src/python-batocera-common/batocera_common/vulkan.py
new file mode 100644
index 00000000000..ce5f1dc8895
--- /dev/null
+++ b/python-src/python-batocera-common/batocera_common/vulkan.py
@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import re
+from functools import cache
+from pathlib import Path
+from typing import Final
+
+import uvloop
+
+from .asyncio import run, run_in_new_uvloop
+from .dataclasses import cached_dataclass, cached_property
+from .key_value_config import KeyValueConfig
+
+_logger: Final = logging.getLogger(__name__)
+
+_BATOCERA_BOOT_CONF: Final = Path('/boot/batocera-boot.conf')
+_VULKAN_INFO_BIN: Final = Path('/usr/bin/vulkaninfo')
+
+_GPU_HEADER_RE: Final = re.compile(r'^GPU(\d+):', re.MULTILINE)
+_KEY_VALUE_RE: Final = re.compile(r'^\s*(?P[A-Za-z0-9]+)\s*=\s*(?P.+?)\s*$', re.MULTILINE)
+_DEVICE_EXTENSIONS_RE: Final = re.compile(
+ r'^\s*Device Extensions:.*?(?=^\s*(?:GPU\d+:|Device Groups:)|\Z)',
+ re.MULTILINE | re.DOTALL,
+)
+_EXTENSION_NAME_RE: Final = re.compile(r'^\s*(VK_[A-Za-z0-9_]+)\s+:', re.MULTILINE)
+
+_VIDEO_QUEUE_EXTENSIONS: Final = frozenset({'VK_KHR_video_queue', 'VK_KHR_video_encode_queue'})
+_ENCODING_EXTENSIONS: Final = {
+ 'VK_KHR_video_encode_h264': 'h264_vulkan',
+ 'VK_KHR_video_encode_h265': 'hevc_vulkan',
+ 'VK_KHR_video_encode_av1': 'av1_vulkan',
+}
+
+
+@cached_dataclass(frozen=True)
+class VulkanGPU:
+ index: int
+ name: str
+ device_type: str
+ uuid: str | None
+ api_version: str
+ extensions: frozenset[str]
+
+ @cached_property
+ def is_discrete(self) -> bool:
+ return self.device_type == 'PHYSICAL_DEVICE_TYPE_DISCRETE_GPU'
+
+
+@cached_dataclass(frozen=True)
+class VulkanInfo:
+ gpus: tuple[VulkanGPU, ...]
+
+ @cached_property
+ def devices(self) -> list[str]:
+ return [gpu.name for gpu in self.gpus]
+
+ @cached_property
+ def default_gpu(self) -> VulkanGPU | None:
+ return self.gpus[0] if self.gpus else None
+
+ @cached_property
+ def discrete_gpu(self) -> VulkanGPU | None:
+ return next((gpu for gpu in self.gpus if gpu.is_discrete), None)
+
+ @cached_property
+ def active_discrete_gpu(self) -> VulkanGPU | None:
+ if self.discrete_gpu is not None and not _is_radeon_prime_disabled():
+ return self.discrete_gpu
+
+ return None
+
+ @cached_property
+ def active_gpu(self) -> VulkanGPU | None:
+ return self.active_discrete_gpu or self.default_gpu
+
+ @cached_property
+ def version(self) -> str | None:
+ if self.active_gpu is not None:
+ return self.active_gpu.api_version
+
+ return None
+
+ @cached_property
+ def encoding_codecs(self) -> list[str]:
+ gpu = self.active_gpu
+ if gpu is None or not _VIDEO_QUEUE_EXTENSIONS.issubset(gpu.extensions):
+ return []
+
+ return [codec for extension, codec in _ENCODING_EXTENSIONS.items() if extension in gpu.extensions]
+
+
+def _parse_api_version(raw_value: str) -> str:
+ value = raw_value.strip()
+ if '(' in value:
+ value = value[: value.index('(')].strip()
+
+ return value
+
+
+def _parse_device_extensions(block: str) -> frozenset[str]:
+ match = _DEVICE_EXTENSIONS_RE.search(block)
+ if match is None:
+ return frozenset()
+
+ return frozenset(_EXTENSION_NAME_RE.findall(match.group(0)))
+
+
+def _parse_gpu_block(index: int, block: str) -> VulkanGPU:
+ properties = {
+ match.group('key'): match.group('value').strip()
+ for match in _KEY_VALUE_RE.finditer(block)
+ if match.group('key') in {'deviceName', 'deviceType', 'deviceUUID', 'apiVersion'}
+ }
+
+ return VulkanGPU(
+ index=index,
+ name=properties.get('deviceName', ''),
+ device_type=properties.get('deviceType', ''),
+ uuid=properties.get('deviceUUID'),
+ api_version=_parse_api_version(properties.get('apiVersion', '')),
+ extensions=_parse_device_extensions(block),
+ )
+
+
+def _parse_vulkaninfo(text: str) -> VulkanInfo | None:
+ matches = list(_GPU_HEADER_RE.finditer(text))
+ gpus: list[VulkanGPU] = []
+
+ for index, match in enumerate(matches):
+ start = match.start()
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
+ gpus.append(_parse_gpu_block(int(match.group(1)), text[start:end]))
+
+ if not gpus:
+ return None
+
+ return VulkanInfo(gpus=tuple(gpus))
+
+
+def _is_radeon_prime_disabled() -> bool:
+ if not _BATOCERA_BOOT_CONF.is_file():
+ return False
+
+ config = KeyValueConfig()
+ config.read(_BATOCERA_BOOT_CONF)
+
+ value = config.get('radeon-prime')
+
+ return value is not None and value.strip() == 'false'
+
+
+def _vulkan_env() -> dict[str, str]:
+ env = dict(os.environ)
+
+ if not env.get('WAYLAND_DISPLAY') and not env.get('DISPLAY'):
+ x_sockets = sorted(Path('/tmp/.X11-unix').glob('X*'))
+ if x_sockets:
+ env['DISPLAY'] = f':{x_sockets[0].name[1:]}'
+
+ return env
+
+
+async def get_vulkan_info() -> VulkanInfo | None:
+ if not _VULKAN_INFO_BIN.exists():
+ _logger.warning('vulkaninfo binary not found.')
+ return None
+
+ try:
+ proc = await run(_VULKAN_INFO_BIN, check=True, text=True, env=_vulkan_env())
+ except Exception:
+ _logger.exception('Error running vulkaninfo')
+ return None
+
+ return _parse_vulkaninfo(proc.stdout)
+
+
+@cache
+def _get_cached_vulkan_info() -> VulkanInfo | None:
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return uvloop.run(get_vulkan_info())
+
+ return run_in_new_uvloop(get_vulkan_info())
+
+
+def is_available() -> bool:
+ info = _get_cached_vulkan_info()
+ return info is not None and bool(info.gpus)
+
+
+def has_discrete_gpu() -> bool:
+ info = _get_cached_vulkan_info()
+ return info is not None and info.active_discrete_gpu is not None
+
+
+def get_discrete_gpu_index() -> str | None:
+ info = _get_cached_vulkan_info()
+ return None if info is None or info.discrete_gpu is None else str(info.discrete_gpu.index)
+
+
+def get_discrete_gpu_name() -> str | None:
+ info = _get_cached_vulkan_info()
+ return None if info is None or info.discrete_gpu is None else (info.discrete_gpu.name or None)
+
+
+def get_default_gpu_name() -> str | None:
+ info = _get_cached_vulkan_info()
+ return None if info is None or info.default_gpu is None else (info.default_gpu.name or None)
+
+
+def get_discrete_gpu_uuid() -> str | None:
+ info = _get_cached_vulkan_info()
+ return None if info is None or info.discrete_gpu is None else info.discrete_gpu.uuid
+
+
+def get_version() -> str:
+ info = _get_cached_vulkan_info()
+ return '' if info is None or info.active_gpu is None else (info.active_gpu.api_version or '')
diff --git a/python-src/python-batocera-common/pyproject.toml b/python-src/python-batocera-common/pyproject.toml
index 865827e74de..e840bc082c5 100644
--- a/python-src/python-batocera-common/pyproject.toml
+++ b/python-src/python-batocera-common/pyproject.toml
@@ -19,3 +19,4 @@ include = ["/batocera_common"]
minversion = "9.0"
addopts = ["--import-mode=importlib", "--cov=batocera_common", "--cov-branch", "--cov-report=term"]
strict = true
+asyncio_mode = "auto"
diff --git a/python-src/python-batocera-common/python-batocera-common.mk b/python-src/python-batocera-common/python-batocera-common.mk
index 0d3b558c549..7f3ef429b8f 100644
--- a/python-src/python-batocera-common/python-batocera-common.mk
+++ b/python-src/python-batocera-common/python-batocera-common.mk
@@ -8,7 +8,8 @@ PYTHON_BATOCERA_COMMON_SETUP_TYPE=hatch
PYTHON_BATOCERA_COMMON_DEPENDENCIES = \
python-typing-extensions \
python-pyyaml \
- python-ruamel-yaml
+ python-ruamel-yaml \
+ sdl3
HOST_PYTHON_BATOCERA_COMMON_SETUP_TYPE=hatch
HOST_PYTHON_BATOCERA_COMMON_DEPENDENCIES = \
diff --git a/python-src/python-batocera-common/tests/conftest.py b/python-src/python-batocera-common/tests/conftest.py
new file mode 100644
index 00000000000..3a8cfa02b9d
--- /dev/null
+++ b/python-src/python-batocera-common/tests/conftest.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+import uvloop
+from pyfakefs import helpers
+from pyfakefs.fake_filesystem import FakeFilesystem, OSType
+from pyfakefs.fake_filesystem_unittest import Patcher
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator, Mapping
+ from types import ModuleType
+
+ from pytest_asyncio.plugin import LoopFactory
+
+
+def pytest_asyncio_loop_factories(config: pytest.Config, item: pytest.Item) -> Mapping[str, LoopFactory]:
+ return {
+ 'uvloop': uvloop.new_event_loop,
+ }
+
+
+@pytest.fixture
+def fs_modules_to_reload() -> list[ModuleType] | None:
+ return
+
+
+@pytest.fixture
+def fs(fs_modules_to_reload: list[ModuleType] | None, monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeFilesystem]:
+ with monkeypatch.context() as mp:
+ # delete these so our fake filesystem does not inherit the temporary directory
+ # of the machine running the tests
+ mp.delenv('TMP', raising=False)
+ mp.delenv('TMPDIR', raising=False)
+ mp.delenv('TEMP', raising=False)
+
+ # batocera runs as root
+ helpers.set_uid(0)
+
+ with Patcher(
+ additional_skip_names=[
+ 'syrupy.utils',
+ 'syrupy.extensions.amber.serializer',
+ 'syrupy.extensions.image',
+ 'syrupy.extensions.single_file',
+ ],
+ modules_to_reload=fs_modules_to_reload,
+ allow_root_user=True,
+ ) as patcher:
+ patcher.fs.os = OSType.LINUX # pyright: ignore
+ yield patcher.fs # pyright: ignore
+
+ helpers.reset_ids()
diff --git a/python-src/python-batocera-common/tests/test_asyncio.py b/python-src/python-batocera-common/tests/test_asyncio.py
new file mode 100644
index 00000000000..03c8e09429e
--- /dev/null
+++ b/python-src/python-batocera-common/tests/test_asyncio.py
@@ -0,0 +1,235 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING
+
+import pytest
+
+from batocera_common.asyncio import create_ready_task, group_tasks, parallel
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
+
+class TestParallel:
+ async def test_single_manager_result(self) -> None:
+ @asynccontextmanager
+ async def manager() -> AsyncGenerator[str]:
+ yield 'ok'
+
+ async with parallel(manager()) as results:
+ assert results == ('ok',)
+
+ async def test_yields_results_in_order(self) -> None:
+ @asynccontextmanager
+ async def manager(value: int) -> AsyncGenerator[int]:
+ yield value
+
+ async with parallel(manager(1), manager(2), manager(3)) as results:
+ assert results == (1, 2, 3)
+
+ async def test_empty_parallel_yields_empty_tuple(self) -> None:
+ async with parallel() as results:
+ assert results == ()
+
+ async def test_enters_concurrently(self) -> None:
+ barrier = asyncio.Barrier(2)
+ started = 0
+
+ @asynccontextmanager
+ async def manager(value: str) -> AsyncGenerator[str]:
+ nonlocal started
+ started += 1
+ await barrier.wait()
+ yield value
+
+ async with parallel(manager('a'), manager('b')) as results:
+ assert results == ('a', 'b')
+ assert started == 2
+
+ async def test_exits_in_reverse_order(self) -> None:
+ log: list[str] = []
+
+ @asynccontextmanager
+ async def manager(name: str) -> AsyncGenerator[str]:
+ try:
+ yield name
+ finally:
+ log.append(f'exit:{name}')
+
+ async with parallel(manager('a'), manager('b'), manager('c')) as results:
+ assert results == ('a', 'b', 'c')
+ assert log == []
+
+ assert log == ['exit:c', 'exit:b', 'exit:a']
+
+ async def test_exits_on_body_exception(self) -> None:
+ log: list[str] = []
+
+ @asynccontextmanager
+ async def manager(name: str) -> AsyncGenerator[str]:
+ try:
+ yield name
+ finally:
+ log.append(f'exit:{name}')
+
+ with pytest.raises(RuntimeError, match='body'):
+ async with parallel(manager('a'), manager('b')):
+ raise RuntimeError('body')
+
+ assert log == ['exit:b', 'exit:a']
+
+ async def test_enter_failure_exits_entered_managers(self) -> None:
+ log: list[str] = []
+ ok_entered = asyncio.Event()
+
+ @asynccontextmanager
+ async def ok() -> AsyncGenerator[str]:
+ log.append('enter:ok')
+ ok_entered.set()
+ try:
+ yield 'ok'
+ finally:
+ log.append('exit:ok')
+
+ class Fail:
+ async def __aenter__(self) -> None:
+ await ok_entered.wait()
+ raise ValueError('boom')
+
+ async def __aexit__(self, *args: object) -> None:
+ return None
+
+ with pytest.raises(ExceptionGroup) as exc_info:
+ async with parallel(ok(), Fail()):
+ pass
+
+ assert log == ['enter:ok', 'exit:ok']
+ assert any(isinstance(exc, ValueError) and str(exc) == 'boom' for exc in exc_info.value.exceptions)
+
+
+class TestGroupTasks:
+ async def test_single_coroutine_result(self) -> None:
+ async def worker() -> str:
+ return 'ok'
+
+ assert await group_tasks(worker()) == ('ok',)
+
+ async def test_returns_results_in_order(self) -> None:
+ async def worker(value: int) -> int:
+ await asyncio.sleep(0)
+ return value
+
+ assert await group_tasks(worker(1), worker(2), worker(3)) == (1, 2, 3)
+
+ async def test_empty_group_returns_empty_tuple(self) -> None:
+ assert await group_tasks() == ()
+
+ async def test_runs_concurrently(self) -> None:
+ barrier = asyncio.Barrier(2)
+ started = 0
+
+ async def worker(value: int) -> int:
+ nonlocal started
+ started += 1
+ await barrier.wait()
+ return value
+
+ assert await group_tasks(worker(1), worker(2)) == (1, 2)
+ assert started == 2
+
+ async def test_failure_cancels_siblings_and_raises(self) -> None:
+ cancelled = asyncio.Event()
+
+ async def fail() -> None:
+ raise ValueError('boom')
+
+ async def hang() -> None:
+ try:
+ await asyncio.Event().wait()
+ except asyncio.CancelledError:
+ cancelled.set()
+ raise
+
+ with pytest.raises(ExceptionGroup) as exc_info:
+ await group_tasks(fail(), hang())
+
+ assert cancelled.is_set()
+ assert any(isinstance(exc, ValueError) and str(exc) == 'boom' for exc in exc_info.value.exceptions)
+
+
+class TestCreateReadyTask:
+ async def test_returns_running_task_after_ready(self) -> None:
+ started = asyncio.Event()
+
+ async def worker(ready: asyncio.Event) -> str:
+ started.set()
+ ready.set()
+ await asyncio.Event().wait()
+ return 'unreachable'
+
+ task = await create_ready_task(worker)
+
+ assert isinstance(task, asyncio.Task)
+ assert started.is_set()
+ assert not task.done()
+
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ async def test_waits_until_ready_is_set(self) -> None:
+ proceed = asyncio.Event()
+ returned = False
+
+ async def worker(ready: asyncio.Event) -> None:
+ await proceed.wait()
+ ready.set()
+
+ async def create() -> asyncio.Task[None]:
+ nonlocal returned
+ task = await create_ready_task(worker)
+ returned = True
+ return task
+
+ create_task = asyncio.create_task(create())
+ await asyncio.sleep(0)
+
+ assert not returned
+ assert not create_task.done()
+
+ proceed.set()
+ task = await create_task
+
+ assert returned
+ await task
+
+ async def test_passes_args_and_kwargs(self) -> None:
+ async def worker(ready: asyncio.Event, value: int, *, label: str) -> tuple[int, str]:
+ ready.set()
+ return value, label
+
+ task = await create_ready_task(worker, 7, label='ok')
+
+ assert await task == (7, 'ok')
+
+ async def test_task_result_available_after_completion(self) -> None:
+ async def worker(ready: asyncio.Event) -> int:
+ ready.set()
+ await asyncio.sleep(0)
+ return 42
+
+ task = await create_ready_task(worker)
+
+ assert await task == 42
+
+ async def test_exception_after_ready_propagates_via_task(self) -> None:
+ async def worker(ready: asyncio.Event) -> None:
+ ready.set()
+ raise RuntimeError('boom')
+
+ task = await create_ready_task(worker)
+
+ with pytest.raises(RuntimeError, match='boom'):
+ await task
diff --git a/python-src/python-batocera-common/tests/test_dataclasses.py b/python-src/python-batocera-common/tests/test_dataclasses.py
new file mode 100644
index 00000000000..ae82b3d9daf
--- /dev/null
+++ b/python-src/python-batocera-common/tests/test_dataclasses.py
@@ -0,0 +1,448 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from batocera_common.dataclasses import cached_dataclass, cached_property
+
+if TYPE_CHECKING:
+ from pytest_mock import MockerFixture
+
+
+class TestCachedDataclassDecoratorForms:
+ def test_without_parentheses(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ obj = MyClass(x=1)
+ assert obj.x == 1
+
+ def test_with_parentheses(self) -> None:
+ @cached_dataclass()
+ class MyClass:
+ x: int
+
+ obj = MyClass(x=2)
+ assert obj.x == 2
+
+ def test_direct_call_with_class(self) -> None:
+ class MyClass:
+ x: int
+
+ Result = cached_dataclass(MyClass)
+ obj = Result(x=3) # type: ignore[call-arg] # pyright: ignore
+ assert obj.x == 3
+
+
+class TestCachedDataclassSlots:
+ def test_has_slots(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ assert hasattr(MyClass, '__slots__')
+
+ def test_cannot_set_arbitrary_attributes(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ obj = MyClass(x=1)
+ with pytest.raises(AttributeError):
+ obj.y = 2 # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+
+
+class TestCachedPropertyBasic:
+ def test_computes_value(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ obj = MyClass(x=5)
+ assert obj.doubled == 10
+
+ def test_caches_value(self, mocker: MockerFixture) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def computed(self) -> int:
+ return self.x * 2
+
+ spy = mocker.spy(MyClass.computed, 'func')
+
+ obj = MyClass(x=5)
+ _ = obj.computed
+ _ = obj.computed
+ _ = obj.computed
+ assert spy.call_count == 1
+
+ def test_class_access_returns_descriptor(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ assert isinstance(MyClass.doubled, cached_property)
+
+ def test_excluded_from_init(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ with pytest.raises(TypeError):
+ MyClass(x=1, doubled=2) # type: ignore[call-arg] # pyright: ignore[reportCallIssue]
+
+ def test_excluded_from_repr(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ obj = MyClass(x=5)
+ _ = obj.doubled
+ assert 'doubled' not in repr(obj)
+
+ def test_excluded_from_eq(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def random_val(self) -> object:
+ return object()
+
+ a = MyClass(x=1)
+ b = MyClass(x=1)
+ _ = a.random_val
+ _ = b.random_val
+ assert a == b
+
+
+class TestCachedPropertyDelete:
+ def test_delete_allows_recomputation(self, mocker: MockerFixture) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def computed(self) -> int:
+ return self.x * 2
+
+ spy = mocker.spy(MyClass.computed, 'func')
+
+ obj = MyClass(x=5)
+ assert obj.computed == 10
+ assert spy.call_count == 1
+
+ del obj.computed
+ assert obj.computed == 10
+ assert spy.call_count == 2
+
+ def test_delete_unset_property_does_not_raise(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def computed(self) -> int:
+ return self.x * 2
+
+ obj = MyClass(x=5)
+ del obj.computed # never accessed, should not raise
+
+
+class TestCachedPropertyFrozen:
+ def test_works_with_frozen_dataclass(self) -> None:
+ @cached_dataclass(frozen=True)
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ obj = MyClass(x=5)
+ assert obj.doubled == 10
+
+ def test_caches_with_frozen_dataclass(self, mocker: MockerFixture) -> None:
+ @cached_dataclass(frozen=True)
+ class MyClass:
+ x: int
+
+ @cached_property
+ def computed(self) -> int:
+ return self.x * 2
+
+ spy = mocker.spy(MyClass.computed, 'func')
+
+ obj = MyClass(x=5)
+ _ = obj.computed
+ _ = obj.computed
+ assert spy.call_count == 1
+
+
+class TestCachedPropertyMultiple:
+ def test_multiple_cached_properties(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_property
+ def tripled(self) -> int:
+ return self.x * 3
+
+ obj = MyClass(x=4)
+ assert obj.doubled == 8
+ assert obj.tripled == 12
+
+ def test_multiple_cached_properties_independent_caching(self, mocker: MockerFixture) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_property
+ def tripled(self) -> int:
+ return self.x * 3
+
+ doubled_spy = mocker.spy(MyClass.doubled, 'func')
+ tripled_spy = mocker.spy(MyClass.tripled, 'func')
+
+ obj = MyClass(x=4)
+ _ = obj.doubled
+ _ = obj.tripled
+ _ = obj.doubled
+ _ = obj.tripled
+ assert doubled_spy.call_count == 1
+ assert tripled_spy.call_count == 1
+
+ def test_delete_one_does_not_affect_other(self, mocker: MockerFixture) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_property
+ def tripled(self) -> int:
+ return self.x * 3
+
+ doubled_spy = mocker.spy(MyClass.doubled, 'func')
+ tripled_spy = mocker.spy(MyClass.tripled, 'func')
+
+ obj = MyClass(x=4)
+ _ = obj.doubled
+ _ = obj.tripled
+ del obj.doubled
+ _ = obj.doubled
+ assert doubled_spy.call_count == 2
+ assert tripled_spy.call_count == 1
+
+
+class TestCachedPropertyWithoutCachedDataclass:
+ def test_get_raises_type_error(self) -> None:
+ class MyClass:
+ @cached_property
+ def value(self) -> int:
+ return 42
+
+ obj = MyClass()
+ with pytest.raises(TypeError, match='cached_property must be used with'):
+ _ = obj.value
+
+ def test_delete_raises_type_error(self) -> None:
+ class MyClass:
+ @cached_property
+ def value(self) -> int:
+ return 42
+
+ obj = MyClass()
+ with pytest.raises(TypeError, match='cached_property must be used with'):
+ del obj.value
+
+
+class TestCachedPropertyMixedFields:
+ def test_regular_and_cached_fields(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+ y: str
+
+ @cached_property
+ def combined(self) -> str:
+ return f'{self.x}-{self.y}'
+
+ obj = MyClass(x=1, y='hello')
+ assert obj.x == 1
+ assert obj.y == 'hello'
+ assert obj.combined == '1-hello'
+
+ def test_multiple_instances_independent(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ a = MyClass(x=3)
+ b = MyClass(x=7)
+ assert a.doubled == 6
+ assert b.doubled == 14
+
+
+class TestCachedPropertyInheritance:
+ def test_subclass_with_cached_property(self) -> None:
+ @cached_dataclass
+ class Parent:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_dataclass
+ class Child(Parent):
+ @cached_property
+ def tripled(self) -> int:
+ return self.x * 3
+
+ obj = Child(x=4)
+ assert obj.doubled == 8
+ assert obj.tripled == 12
+
+ def test_subclass_with_own_fields(self) -> None:
+ @cached_dataclass
+ class Parent:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_dataclass
+ class Child(Parent):
+ y: int
+
+ @cached_property
+ def summed(self) -> int:
+ return self.x + self.y
+
+ obj = Child(x=3, y=7)
+ assert obj.doubled == 6
+ assert obj.summed == 10
+
+ def test_subclass_cached_properties_cache_independently(self, mocker: MockerFixture) -> None:
+ @cached_dataclass
+ class Parent:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_dataclass
+ class Child(Parent):
+ @cached_property
+ def tripled(self) -> int:
+ return self.x * 3
+
+ doubled_spy = mocker.spy(Child.doubled, 'func')
+ tripled_spy = mocker.spy(Child.tripled, 'func')
+
+ obj = Child(x=4)
+ _ = obj.doubled
+ _ = obj.tripled
+ _ = obj.doubled
+ _ = obj.tripled
+ assert doubled_spy.call_count == 1
+ assert tripled_spy.call_count == 1
+
+ def test_subclass_without_new_cached_properties(self) -> None:
+ @cached_dataclass
+ class Parent:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_dataclass
+ class Child(Parent):
+ pass
+
+ obj = Child(x=5)
+ assert obj.doubled == 10
+
+ def test_subclass_override_property(self) -> None:
+ @cached_dataclass
+ class Parent:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ @cached_dataclass
+ class Child(Parent):
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 4
+
+ parent = Parent(x=5)
+ child = Child(x=5)
+ assert parent.doubled == 10
+ assert child.doubled == 20
+
+
+class TestCachedPropertyDoc:
+ def test_preserves_docstring(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ """Return x doubled."""
+ return self.x * 2
+
+ assert MyClass.doubled.__doc__ == 'Return x doubled.'
+
+ def test_no_docstring(self) -> None:
+ @cached_dataclass
+ class MyClass:
+ x: int
+
+ @cached_property
+ def doubled(self) -> int:
+ return self.x * 2
+
+ assert MyClass.doubled.__doc__ is None
diff --git a/python-src/python-batocera-common/tests/test_dict.py b/python-src/python-batocera-common/tests/test_dict.py
new file mode 100644
index 00000000000..2db2d7d418f
--- /dev/null
+++ b/python-src/python-batocera-common/tests/test_dict.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from types import MappingProxyType
+from typing import TYPE_CHECKING
+
+from batocera_common.dict import merge
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+
+class TestMergeFlat:
+ def test_adds_new_keys(self) -> None:
+ destination = {'a': 1}
+ merge(destination, {'b': 2})
+ assert destination == {'a': 1, 'b': 2}
+
+ def test_overwrites_existing_keys(self) -> None:
+ destination = {'a': 1, 'b': 2}
+ merge(destination, {'b': 3})
+ assert destination == {'a': 1, 'b': 3}
+
+ def test_empty_source_leaves_destination_unchanged(self) -> None:
+ destination = {'a': 1}
+ merge(destination, {})
+ assert destination == {'a': 1}
+
+ def test_empty_destination_copies_source(self) -> None:
+ destination: dict[str, object] = {}
+ merge(destination, {'a': 1, 'b': 2})
+ assert destination == {'a': 1, 'b': 2}
+
+
+class TestMergeNested:
+ def test_merges_nested_dicts(self) -> None:
+ destination = {'a': {'x': 1, 'y': 2}}
+ merge(destination, {'a': {'y': 3, 'z': 4}})
+ assert destination == {'a': {'x': 1, 'y': 3, 'z': 4}}
+
+ def test_merges_deeply_nested_dicts(self) -> None:
+ destination = {'a': {'b': {'c': 1, 'd': 2}}}
+ merge(destination, {'a': {'b': {'d': 3, 'e': 4}}})
+ assert destination == {'a': {'b': {'c': 1, 'd': 3, 'e': 4}}}
+
+ def test_adds_nested_dict_for_new_key(self) -> None:
+ destination = {'a': 1}
+ merge(destination, {'b': {'x': 2}})
+ assert destination == {'a': 1, 'b': {'x': 2}}
+
+ def test_replaces_non_dict_with_dict(self) -> None:
+ destination = {'a': 1}
+ merge(destination, {'a': {'x': 2}})
+ assert destination == {'a': {'x': 2}}
+
+ def test_replaces_dict_with_non_dict(self) -> None:
+ destination = {'a': {'x': 1}}
+ merge(destination, {'a': 2})
+ assert destination == {'a': 2}
+
+ def test_replaces_dict_with_list(self) -> None:
+ destination = {'a': {'x': 1}}
+ merge(destination, {'a': [1, 2]})
+ assert destination == {'a': [1, 2]}
+
+ def test_does_not_merge_lists(self) -> None:
+ destination = {'a': [1, 2]}
+ merge(destination, {'a': [3, 4]})
+ assert destination == {'a': [3, 4]}
+
+
+class TestMergeMappingTypes:
+ def test_accepts_mapping_proxy_as_source(self) -> None:
+ destination = {'a': {'x': 1}}
+ source: Mapping[str, object] = MappingProxyType({'a': {'y': 2}, 'b': 3})
+ merge(destination, source)
+ assert destination == {'a': {'x': 1, 'y': 2}, 'b': 3}
+
+ def test_merges_mapping_into_nested_dict(self) -> None:
+ destination = {'a': {'x': 1}}
+ merge(destination, {'a': MappingProxyType({'y': 2})})
+ assert destination == {'a': {'x': 1, 'y': 2}}
+
+
+class TestMergeInPlace:
+ def test_mutates_destination_in_place(self) -> None:
+ destination = {'a': 1}
+ result = merge(destination, {'b': 2})
+ assert result is None
+ assert destination == {'a': 1, 'b': 2}
+
+ def test_does_not_mutate_source(self) -> None:
+ destination = {'a': {'x': 1}}
+ source = {'a': {'y': 2}}
+ merge(destination, source)
+ assert source == {'a': {'y': 2}}
+
+ def test_nested_merge_preserves_destination_subdict_identity(self) -> None:
+ nested = {'x': 1}
+ destination = {'a': nested}
+ merge(destination, {'a': {'y': 2}})
+ assert destination['a'] is nested
+ assert nested == {'x': 1, 'y': 2}
diff --git a/python-src/python-batocera-common/tests/test_fs.py b/python-src/python-batocera-common/tests/test_fs.py
index eddfef7a6e0..5fb6457416c 100644
--- a/python-src/python-batocera-common/tests/test_fs.py
+++ b/python-src/python-batocera-common/tests/test_fs.py
@@ -1,11 +1,12 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from pathlib import Path
+
+import pytest
from batocera_common.fs import directory_differences
-if TYPE_CHECKING:
- from pathlib import Path
+pytestmark = pytest.mark.usefixtures('fs')
def _write(path: Path, content: str = '') -> None:
@@ -14,33 +15,33 @@ def _write(path: Path, content: str = '') -> None:
class TestDirectoryDifferencesBool:
- def test_identical_directories_are_falsy(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_identical_directories_are_falsy(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'a.txt', 'same')
_write(right / 'a.txt', 'same')
assert bool(directory_differences(left, right)) is False
- def test_empty_directories_are_falsy(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_empty_directories_are_falsy(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
left.mkdir()
right.mkdir()
assert bool(directory_differences(left, right)) is False
- def test_differing_directories_are_truthy(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_differing_directories_are_truthy(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'a.txt', 'one')
_write(right / 'a.txt', 'two')
assert bool(directory_differences(left, right)) is True
- def test_left_only_file_is_truthy(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_left_only_file_is_truthy(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'extra.txt', 'x')
right.mkdir()
@@ -48,25 +49,25 @@ def test_left_only_file_is_truthy(self, tmp_path: Path) -> None:
class TestDirectoryDifferencesReport:
- def test_identical_directories_report(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_identical_directories_report(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'a.txt', 'same')
_write(right / 'a.txt', 'same')
assert directory_differences(left, right).report() == 'Directories are identical'
- def test_empty_directories_report(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_empty_directories_report(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
left.mkdir()
right.mkdir()
assert directory_differences(left, right).report() == 'Directories are identical'
- def test_reports_file_missing_in_right(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_reports_file_missing_in_right(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'only_left.txt', 'x')
right.mkdir()
@@ -74,9 +75,9 @@ def test_reports_file_missing_in_right(self, tmp_path: Path) -> None:
assert report == f'Missing in {right}:\n only_left.txt'
- def test_reports_file_extra_in_right(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_reports_file_extra_in_right(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
left.mkdir()
_write(right / 'only_right.txt', 'x')
@@ -84,9 +85,9 @@ def test_reports_file_extra_in_right(self, tmp_path: Path) -> None:
assert report == f'Extra in {right}:\n only_right.txt'
- def test_reports_different_file(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_reports_different_file(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'changed.txt', 'one')
_write(right / 'changed.txt', 'two')
@@ -94,10 +95,10 @@ def test_reports_different_file(self, tmp_path: Path) -> None:
assert report == 'Different files:\n changed.txt'
- def test_reports_same_size_different_content(self, tmp_path: Path) -> None:
+ def test_reports_same_size_different_content(self) -> None:
# shallow=False means same-size files are still compared by content
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'changed.txt', 'aaa')
_write(right / 'changed.txt', 'bbb')
@@ -105,9 +106,9 @@ def test_reports_same_size_different_content(self, tmp_path: Path) -> None:
assert report == 'Different files:\n changed.txt'
- def test_reports_all_categories_together(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_reports_all_categories_together(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'gone.txt', 'x')
_write(left / 'changed.txt', 'one')
_write(right / 'changed.txt', 'two')
@@ -119,9 +120,9 @@ def test_reports_all_categories_together(self, tmp_path: Path) -> None:
f'Missing in {right}:\n gone.txt\nExtra in {right}:\n added.txt\nDifferent files:\n changed.txt'
)
- def test_reports_nested_differences_with_relative_paths(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_reports_nested_differences_with_relative_paths(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'sub' / 'deep' / 'file.txt', 'one')
_write(right / 'sub' / 'deep' / 'file.txt', 'two')
@@ -129,9 +130,9 @@ def test_reports_nested_differences_with_relative_paths(self, tmp_path: Path) ->
assert report == 'Different files:\n sub/deep/file.txt'
- def test_reports_subdirectory_only_in_left(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_reports_subdirectory_only_in_left(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'sub' / 'file.txt', 'x')
right.mkdir()
@@ -139,9 +140,9 @@ def test_reports_subdirectory_only_in_left(self, tmp_path: Path) -> None:
assert report == f'Missing in {right}:\n sub'
- def test_report_can_be_called_multiple_times(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_report_can_be_called_multiple_times(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'changed.txt', 'one')
_write(right / 'changed.txt', 'two')
@@ -149,9 +150,9 @@ def test_report_can_be_called_multiple_times(self, tmp_path: Path) -> None:
assert differences.report() == differences.report()
- def test_report_is_unaffected_by_prior_bool_check(self, tmp_path: Path) -> None:
- left = tmp_path / 'left'
- right = tmp_path / 'right'
+ def test_report_is_unaffected_by_prior_bool_check(self) -> None:
+ left = Path('/left')
+ right = Path('/right')
_write(left / 'changed.txt', 'one')
_write(right / 'changed.txt', 'two')
diff --git a/python-src/python-batocera-common/tests/test_key_value_config.py b/python-src/python-batocera-common/tests/test_key_value_config.py
new file mode 100644
index 00000000000..d261c9f9250
--- /dev/null
+++ b/python-src/python-batocera-common/tests/test_key_value_config.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from batocera_common.key_value_config import KeyValueConfig
+
+pytestmark = pytest.mark.usefixtures('fs')
+
+
+class TestKeyValueConfigGetSet:
+ def test_set_and_get_item(self) -> None:
+ config = KeyValueConfig()
+ config['foo'] = 'bar'
+ assert config['foo'] == 'bar'
+
+ def test_get_with_default(self) -> None:
+ config = KeyValueConfig()
+ assert config.get('missing') is None
+ assert config.get('missing', 'fallback') == 'fallback'
+
+ def test_get_existing_key(self) -> None:
+ config = KeyValueConfig()
+ config['foo'] = 'bar'
+ assert config.get('foo') == 'bar'
+ assert config.get('foo', 'fallback') == 'bar'
+
+ def test_contains(self) -> None:
+ config = KeyValueConfig()
+ config['foo'] = 'bar'
+ assert 'foo' in config
+ assert 'missing' not in config
+
+ def test_delitem(self) -> None:
+ config = KeyValueConfig()
+ config['foo'] = 'bar'
+ del config['foo']
+ assert 'foo' not in config
+
+ def test_preserves_key_case(self) -> None:
+ config = KeyValueConfig()
+ config['MixedCase'] = 'value'
+ assert config['MixedCase'] == 'value'
+ assert 'mixedcase' not in config
+
+
+class TestKeyValueConfigReadWrite:
+ def test_write_and_read_roundtrip(self) -> None:
+ path = Path('/config.cfg')
+ config = KeyValueConfig()
+ config['a'] = '1'
+ config['b'] = 'two'
+ config.write(path)
+
+ loaded = KeyValueConfig()
+ loaded.read(path)
+
+ assert loaded['a'] == '1'
+ assert loaded['b'] == 'two'
+ assert path.read_text() == 'a=1\nb=two\n'
+
+ def test_write_with_separator(self) -> None:
+ path = Path('/config.cfg')
+ config = KeyValueConfig(' ')
+ config['key'] = 'value'
+ config.write(path)
+
+ assert path.read_text() == 'key = value\n'
+
+ def test_read_missing_file_does_not_raise(self) -> None:
+ config = KeyValueConfig()
+ config.read('/does/not/exist.cfg')
+ assert config.get('anything') is None
+
+ def test_read_updates_existing_keys(self) -> None:
+ path = Path('/config.cfg')
+ path.write_text('from_file=yes\n', encoding='latin1')
+
+ config = KeyValueConfig()
+ config['from_file'] = 'no'
+ config['other'] = 'kept'
+ config.read(path)
+
+ assert config['from_file'] == 'yes'
+ assert config['other'] == 'kept'
+
+ def test_read_latin1_content(self) -> None:
+ path = Path('/config.cfg')
+ path.write_bytes(b'name=caf\xe9\n')
+
+ config = KeyValueConfig()
+ config.read(path)
+
+ assert config['name'] == 'café'
+
+
+class TestKeyValueConfigSection:
+ def test_section_returns_matching_keys(self) -> None:
+ config = KeyValueConfig()
+ config['global.shader'] = 'crt'
+ config['global.smooth'] = '1'
+ config['nes.core'] = 'fceumm'
+
+ assert config.section('global') == {'shader': 'crt', 'smooth': '1'}
+ assert config.section('nes') == {'core': 'fceumm'}
+
+ def test_section_items_is_iterator(self) -> None:
+ config = KeyValueConfig()
+ config['global.a'] = '1'
+ config['global.b'] = '2'
+
+ assert list(config.section_items('global')) == [('a', '1'), ('b', '2')]
+
+ def test_section_skips_default_values_by_default(self) -> None:
+ config = KeyValueConfig()
+ config['sys.a'] = 'keep'
+ config['sys.b'] = ''
+ config['sys.c'] = 'default'
+ config['sys.d'] = 'auto'
+
+ assert config.section('sys') == {'a': 'keep'}
+
+ def test_section_keep_defaults(self) -> None:
+ config = KeyValueConfig()
+ config['sys.a'] = 'keep'
+ config['sys.b'] = ''
+ config['sys.c'] = 'default'
+ config['sys.d'] = 'auto'
+
+ assert config.section('sys', keep_defaults=True) == {
+ 'a': 'keep',
+ 'b': '',
+ 'c': 'default',
+ 'd': 'auto',
+ }
+
+ def test_section_with_special_characters(self) -> None:
+ config = KeyValueConfig()
+ config['nes["Game Name"].shader'] = 'crt'
+ config['nes["Game Name"].smooth'] = '1'
+ config['nes.core'] = 'fceumm'
+
+ assert config.section('nes["Game Name"]') == {'shader': 'crt', 'smooth': '1'}
+
+ def test_section_with_folder_path(self) -> None:
+ config = KeyValueConfig()
+ config['snes.folder["/userdata/roms/snes"].ratio'] = 'core'
+ config['snes.ratio'] = 'auto'
+
+ assert config.section('snes.folder["/userdata/roms/snes"]', keep_defaults=True) == {
+ 'ratio': 'core',
+ }
+
+ def test_empty_section(self) -> None:
+ config = KeyValueConfig()
+ config['other.key'] = 'value'
+ assert config.section('missing') == {}
+
+
+class TestKeyValueConfigRemove:
+ def test_remove_all_starting_with(self) -> None:
+ config = KeyValueConfig()
+ config['foo.a'] = '1'
+ config['foo.b'] = '2'
+ config['foobar'] = '3'
+ config['bar'] = '4'
+
+ config.remove_all_starting_with('foo')
+
+ assert 'foo.a' not in config
+ assert 'foo.b' not in config
+ assert 'foobar' not in config
+ assert config['bar'] == '4'
+
+ def test_remove_section(self) -> None:
+ config = KeyValueConfig()
+ config['sys.a'] = '1'
+ config['sys.b'] = '2'
+ config['sysextra'] = '3'
+ config['other'] = '4'
+
+ config.remove_section('sys')
+
+ assert 'sys.a' not in config
+ assert 'sys.b' not in config
+ assert config['sysextra'] == '3'
+ assert config['other'] == '4'
diff --git a/python-src/python-batocera-common/tests/test_math.py b/python-src/python-batocera-common/tests/test_math.py
new file mode 100644
index 00000000000..40875c342c2
--- /dev/null
+++ b/python-src/python-batocera-common/tests/test_math.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import pytest
+
+from batocera_common.math import clamp
+
+
+class TestClamp:
+ @pytest.mark.parametrize(
+ ('value', 'lower', 'upper', 'expected'),
+ [
+ (5, 0, 10, 5),
+ (0, 0, 10, 0),
+ (10, 0, 10, 10),
+ (-1, 0, 10, 0),
+ (11, 0, 10, 10),
+ (5, 5, 5, 5),
+ (-5, -10, -1, -5),
+ (-11, -10, -1, -10),
+ (0, -10, -1, -1),
+ ],
+ )
+ def test_integers(self, value: int, lower: int, upper: int, expected: int) -> None:
+ assert clamp(value, lower, upper) == expected
+
+ @pytest.mark.parametrize(
+ ('value', 'lower', 'upper', 'expected'),
+ [
+ (0.5, 0.0, 1.0, 0.5),
+ (-0.1, 0.0, 1.0, 0.0),
+ (1.1, 0.0, 1.0, 1.0),
+ (0.0, 0.0, 1.0, 0.0),
+ (1.0, 0.0, 1.0, 1.0),
+ ],
+ )
+ def test_floats(self, value: float, lower: float, upper: float, expected: float) -> None:
+ assert clamp(value, lower, upper) == expected
+
+ @pytest.mark.parametrize(
+ ('value', 'lower', 'upper', 'expected'),
+ [
+ ('m', 'a', 'z', 'm'),
+ ('A', 'a', 'z', 'a'),
+ ('{', 'a', 'z', 'z'),
+ ('a', 'a', 'z', 'a'),
+ ('z', 'a', 'z', 'z'),
+ ],
+ )
+ def test_strings(self, value: str, lower: str, upper: str, expected: str) -> None:
+ assert clamp(value, lower, upper) == expected
+
+ def test_equal_bounds(self) -> None:
+ assert clamp(42, 42, 42) == 42
+ assert clamp(0, 42, 42) == 42
+ assert clamp(100, 42, 42) == 42
diff --git a/python-src/python-batocera-common/tests/test_vulkan.py b/python-src/python-batocera-common/tests/test_vulkan.py
new file mode 100644
index 00000000000..c3accbf8870
--- /dev/null
+++ b/python-src/python-batocera-common/tests/test_vulkan.py
@@ -0,0 +1,379 @@
+from __future__ import annotations
+
+from subprocess import CalledProcessError
+from typing import TYPE_CHECKING
+
+import pytest
+
+from batocera_common import vulkan
+
+if TYPE_CHECKING:
+ from unittest.mock import AsyncMock
+
+ import pytest_mock
+ from pyfakefs.fake_filesystem import FakeFilesystem
+
+SUMMARY_OUTPUT = """\
+==========
+VULKANINFO
+==========
+
+Vulkan Instance Version: 1.4.341
+
+Devices:
+========
+GPU0:
+ apiVersion = 1.4.328
+ driverVersion = 25.3.5
+ vendorID = 0x1002
+ deviceID = 0x744c
+ deviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU
+ deviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)
+ driverID = DRIVER_ID_MESA_RADV
+ driverName = radv
+ driverInfo = Mesa 25.3.5-arch1.1
+ conformanceVersion = 1.4.0.0
+ deviceUUID = 00000000-2d00-0000-0000-000000000000
+ driverUUID = 414d442d-4d45-5341-2d44-525600000000
+GPU1:
+ apiVersion = 1.4.328
+ driverVersion = 25.3.5
+ vendorID = 0x10005
+ deviceID = 0x0000
+ deviceType = PHYSICAL_DEVICE_TYPE_CPU
+ deviceName = llvmpipe (LLVM 21.1.6, 256 bits)
+ driverID = DRIVER_ID_MESA_LLVMPIPE
+ driverName = llvmpipe
+ driverInfo = Mesa 25.3.5-arch1.1 (LLVM 21.1.6)
+ conformanceVersion = 1.3.1.1
+ deviceUUID = 6d657361-3235-2e33-2e35-2d6172636800
+ driverUUID = 6c6c766d-7069-7065-5555-494400000000
+"""
+
+HYBRID_OUTPUT = """\
+Devices:
+========
+GPU0:
+ apiVersion = 1.3.280
+ deviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU
+ deviceName = Intel(R) UHD Graphics 630
+ deviceUUID = 11111111-1111-1111-1111-111111111111
+GPU1:
+ apiVersion = 1.4.328
+ deviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU
+ deviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)
+ deviceUUID = 00000000-2d00-0000-0000-000000000000
+"""
+
+FULL_GPU0_OUTPUT = """\
+GPU0:
+========
+\tVkPhysicalDeviceProperties:
+\t---------------------------
+\t\tapiVersion = 1.4.328 (4210991)
+\t\tdriverVersion = 25.3.5
+\t\tvendorID = 0x1002
+\t\tdeviceID = 0x744c
+\t\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU
+\t\tdeviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)
+
+\tDevice Extensions: count = 4
+\t--------------------------
+\t\tVK_KHR_swapchain : extension revision 70
+\t\tVK_KHR_video_encode_av1 : extension revision 1
+\t\tVK_KHR_video_encode_queue : extension revision 8
+\t\tVK_KHR_video_queue : extension revision 8
+GPU1:
+========
+\tVkPhysicalDeviceProperties:
+\t---------------------------
+\t\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU
+\t\tdeviceName = llvmpipe (LLVM 21.1.6, 256 bits)
+"""
+
+INTEGRATED_GPU = vulkan.VulkanGPU(
+ index=0,
+ name='Intel(R) UHD Graphics 630',
+ device_type='PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU',
+ uuid='11111111-1111-1111-1111-111111111111',
+ api_version='1.3.280',
+ extensions=frozenset(),
+)
+
+DISCRETE_GPU = vulkan.VulkanGPU(
+ index=1,
+ name='AMD Radeon RX 7900 XTX (RADV NAVI31)',
+ device_type='PHYSICAL_DEVICE_TYPE_DISCRETE_GPU',
+ uuid='00000000-2d00-0000-0000-000000000000',
+ api_version='1.4.328',
+ extensions=frozenset(),
+)
+
+
+@pytest.fixture(autouse=True)
+def reset_vulkan_info_cache() -> None:
+ vulkan._get_cached_vulkan_info.cache_clear()
+
+
+@pytest.fixture
+def mock_vulkaninfo(request: pytest.FixtureRequest, mocker: pytest_mock.MockFixture) -> AsyncMock:
+ from batocera_common.asyncio import AsyncCompletedProcess
+
+ if request.param == 'summary':
+ output = SUMMARY_OUTPUT
+ elif request.param == 'hybrid':
+ output = HYBRID_OUTPUT
+ elif request.param == 'full':
+ output = FULL_GPU0_OUTPUT
+ elif request.param == 'none':
+ output = ''
+ elif request.param == 'error':
+ output = None
+ else:
+ raise ValueError('unknown param')
+
+ if output is not None:
+ return_value = AsyncCompletedProcess(returncode=0, stdout=output, stderr='')
+ side_effect = None
+ else:
+ return_value = None
+ side_effect = CalledProcessError(1, ['/usr/bin/vulkaninfo'])
+
+ return mocker.patch.object(
+ vulkan,
+ 'run',
+ return_value=return_value,
+ side_effect=side_effect,
+ new_callable=mocker.AsyncMock,
+ )
+
+
+async def test_get_vulkan_info_no_binary() -> None:
+ info = await vulkan.get_vulkan_info()
+
+ assert info is None
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['error'], indirect=True)
+@pytest.mark.usefixtures('mock_vulkaninfo')
+async def test_get_vulkan_info_run_exception(fs: FakeFilesystem) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+
+ info = await vulkan.get_vulkan_info()
+
+ assert info is None
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['none'], indirect=True)
+@pytest.mark.usefixtures('mock_vulkaninfo')
+async def test_get_vulkan_info_no_gpus(fs: FakeFilesystem) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+
+ info = await vulkan.get_vulkan_info()
+
+ assert info is None
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['summary'], indirect=True)
+@pytest.mark.usefixtures('mock_vulkaninfo')
+async def test_get_vulkan_info_summary(fs: FakeFilesystem) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+
+ info = await vulkan.get_vulkan_info()
+
+ assert info is not None
+ assert len(info.gpus) == 2
+ assert info.devices == [
+ 'AMD Radeon RX 7900 XTX (RADV NAVI31)',
+ 'llvmpipe (LLVM 21.1.6, 256 bits)',
+ ]
+ assert info.default_gpu is not None
+ assert info.default_gpu.name == 'AMD Radeon RX 7900 XTX (RADV NAVI31)'
+ assert info.discrete_gpu is not None
+ assert info.discrete_gpu.index == 0
+ assert info.discrete_gpu.uuid == '00000000-2d00-0000-0000-000000000000'
+ assert info.discrete_gpu.api_version == '1.4.328'
+ assert info.active_discrete_gpu == info.discrete_gpu
+ assert info.active_gpu == info.discrete_gpu
+ assert info.version == '1.4.328'
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['full'], indirect=True)
+@pytest.mark.usefixtures('mock_vulkaninfo')
+async def test_get_vulkan_info_device_extensions(fs: FakeFilesystem) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+
+ info = await vulkan.get_vulkan_info()
+
+ assert info is not None
+ assert info.discrete_gpu is not None
+ assert 'VK_KHR_video_queue' in info.discrete_gpu.extensions
+ assert 'VK_KHR_video_encode_queue' in info.discrete_gpu.extensions
+ assert 'VK_KHR_video_encode_av1' in info.discrete_gpu.extensions
+ assert info.discrete_gpu.api_version == '1.4.328'
+ assert info.encoding_codecs == ['av1_vulkan']
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['summary'], indirect=True)
+async def test_get_vulkan_info_preserves_display(
+ fs: FakeFilesystem,
+ monkeypatch: pytest.MonkeyPatch,
+ mock_vulkaninfo: AsyncMock,
+) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+ monkeypatch.setenv('DISPLAY', ':1')
+ monkeypatch.delenv('WAYLAND_DISPLAY', raising=False)
+
+ await vulkan.get_vulkan_info()
+
+ assert mock_vulkaninfo.await_count == 1
+ assert mock_vulkaninfo.await_args_list[0].kwargs['env'].get('DISPLAY') == ':1'
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['summary'], indirect=True)
+async def test_get_vulkan_info_preserves_wayland_display(
+ fs: FakeFilesystem,
+ monkeypatch: pytest.MonkeyPatch,
+ mock_vulkaninfo: AsyncMock,
+) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+ monkeypatch.setenv('WAYLAND_DISPLAY', 'wayland-0')
+ monkeypatch.delenv('DISPLAY', raising=False)
+
+ await vulkan.get_vulkan_info()
+
+ assert mock_vulkaninfo.await_count == 1
+ env = mock_vulkaninfo.await_args_list[0].kwargs['env']
+ assert env.get('WAYLAND_DISPLAY') == 'wayland-0'
+ assert 'DISPLAY' not in env
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['summary'], indirect=True)
+async def test_get_vulkan_info_discovers_display_from_x_socket(
+ fs: FakeFilesystem,
+ monkeypatch: pytest.MonkeyPatch,
+ mock_vulkaninfo: AsyncMock,
+) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+ fs.create_file('/tmp/.X11-unix/X0') # pyright: ignore[reportUnknownMemberType]
+ monkeypatch.delenv('DISPLAY', raising=False)
+ monkeypatch.delenv('WAYLAND_DISPLAY', raising=False)
+
+ await vulkan.get_vulkan_info()
+
+ assert mock_vulkaninfo.await_count == 1
+ env = mock_vulkaninfo.await_args_list[0].kwargs['env']
+ assert env['DISPLAY'] == ':0'
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['summary'], indirect=True)
+async def test_get_vulkan_info_uses_lowest_x_socket(
+ fs: FakeFilesystem,
+ monkeypatch: pytest.MonkeyPatch,
+ mock_vulkaninfo: AsyncMock,
+) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+ fs.create_file('/tmp/.X11-unix/X1') # pyright: ignore[reportUnknownMemberType]
+ fs.create_file('/tmp/.X11-unix/X0') # pyright: ignore[reportUnknownMemberType]
+ monkeypatch.delenv('DISPLAY', raising=False)
+ monkeypatch.delenv('WAYLAND_DISPLAY', raising=False)
+
+ await vulkan.get_vulkan_info()
+
+ assert mock_vulkaninfo.await_count == 1
+ env = mock_vulkaninfo.await_args_list[0].kwargs['env']
+ assert env['DISPLAY'] == ':0'
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['summary'], indirect=True)
+async def test_get_vulkan_info_no_display_without_x_sockets(
+ fs: FakeFilesystem,
+ monkeypatch: pytest.MonkeyPatch,
+ mock_vulkaninfo: AsyncMock,
+) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+ monkeypatch.delenv('DISPLAY', raising=False)
+ monkeypatch.delenv('WAYLAND_DISPLAY', raising=False)
+
+ await vulkan.get_vulkan_info()
+
+ assert mock_vulkaninfo.await_count == 1
+ env = mock_vulkaninfo.await_args_list[0].kwargs['env']
+ assert 'DISPLAY' not in env
+
+
+def test_vulkan_info_encoding_codecs() -> None:
+ gpu = vulkan.VulkanGPU(
+ index=0,
+ name='AMD Radeon RX 7900 XTX (RADV NAVI31)',
+ device_type='PHYSICAL_DEVICE_TYPE_DISCRETE_GPU',
+ uuid=None,
+ api_version='1.4.328',
+ extensions=frozenset(
+ {
+ 'VK_KHR_video_queue',
+ 'VK_KHR_video_encode_queue',
+ 'VK_KHR_video_encode_av1',
+ }
+ ),
+ )
+ info = vulkan.VulkanInfo(gpus=(gpu,))
+
+ assert info.encoding_codecs == ['av1_vulkan']
+
+
+def test_vulkan_info_active_discrete_respects_radeon_prime(fs: FakeFilesystem) -> None:
+ fs.create_file( # pyright: ignore[reportUnknownMemberType]
+ '/boot/batocera-boot.conf', contents='radeon-prime=false\n'
+ )
+
+ info = vulkan.VulkanInfo(gpus=(INTEGRATED_GPU, DISCRETE_GPU))
+
+ assert info.discrete_gpu == DISCRETE_GPU
+ assert info.active_discrete_gpu is None
+ assert info.active_gpu == INTEGRATED_GPU
+ assert info.version == '1.3.280'
+
+
+def test_vulkan_info_discrete_gpu_without_active_discrete(fs: FakeFilesystem) -> None:
+ fs.create_file( # pyright: ignore[reportUnknownMemberType]
+ '/boot/batocera-boot.conf', contents='radeon-prime=false\n'
+ )
+
+ info = vulkan.VulkanInfo(gpus=(INTEGRATED_GPU, DISCRETE_GPU))
+
+ assert info.discrete_gpu is not None
+ assert info.discrete_gpu.name == 'AMD Radeon RX 7900 XTX (RADV NAVI31)'
+ assert info.discrete_gpu.index == 1
+ assert info.discrete_gpu.uuid == '00000000-2d00-0000-0000-000000000000'
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['summary'], indirect=True)
+@pytest.mark.usefixtures('mock_vulkaninfo')
+def test_sync_helpers(fs: FakeFilesystem) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+
+ assert vulkan.is_available() is True
+ assert vulkan.has_discrete_gpu() is True
+ assert vulkan.get_discrete_gpu_index() == '0'
+ assert vulkan.get_discrete_gpu_name() == 'AMD Radeon RX 7900 XTX (RADV NAVI31)'
+ assert vulkan.get_default_gpu_name() == 'AMD Radeon RX 7900 XTX (RADV NAVI31)'
+ assert vulkan.get_discrete_gpu_uuid() == '00000000-2d00-0000-0000-000000000000'
+ assert vulkan.get_version() == '1.4.328'
+
+
+@pytest.mark.parametrize('mock_vulkaninfo', ['hybrid'], indirect=True)
+@pytest.mark.usefixtures('mock_vulkaninfo')
+def test_sync_helpers_respect_radeon_prime(fs: FakeFilesystem) -> None:
+ fs.create_file('/usr/bin/vulkaninfo') # pyright: ignore[reportUnknownMemberType]
+ fs.create_file( # pyright: ignore[reportUnknownMemberType]
+ '/boot/batocera-boot.conf', contents='radeon-prime=false\n'
+ )
+
+ assert vulkan.is_available() is True
+ assert vulkan.has_discrete_gpu() is False
+ assert vulkan.get_discrete_gpu_name() == 'AMD Radeon RX 7900 XTX (RADV NAVI31)'
+ assert vulkan.get_discrete_gpu_index() == '1'
+ assert vulkan.get_discrete_gpu_uuid() == '00000000-2d00-0000-0000-000000000000'
+ assert vulkan.get_default_gpu_name() == 'Intel(R) UHD Graphics 630'
+ assert vulkan.get_version() == '1.3.280'
diff --git a/uv.lock b/uv.lock
index d7de1eb17e6..242431a150b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -8,8 +8,21 @@ members = [
"batocera-common",
"batocera-configgen",
"batocera-es-system",
+ "batocera-launch",
+ "batocera-launch-cdogs",
+ "batocera-launch-cgenius",
+ "batocera-launch-drastic",
+ "batocera-launch-fallout",
+ "batocera-launch-flycast",
+ "batocera-launch-kodi",
+ "batocera-launch-openjazz",
+ "batocera-launch-openjk",
+ "batocera-launch-openjkdf2",
+ "batocera-launch-openmohaa",
+ "batocera-launch-rpcs3",
]
constraints = [
+ { name = "aiohttp", specifier = "==3.13.5" },
{ name = "configobj", specifier = "==5.0.8" },
{ name = "evdev", specifier = "==1.9.3" },
{ name = "ffmpeg-python", git = "https://github.com/kkroening/ffmpeg-python.git?rev=df129c7ba30aaa9ffffb81a48f53aa7253b0b4e6" },
@@ -23,21 +36,105 @@ constraints = [
{ name = "ruamel-yaml", specifier = "==0.19.0" },
{ name = "toml", specifier = "==0.10.2" },
{ name = "typing-extensions", specifier = "==4.15.0" },
+ { name = "uvloop", specifier = "==0.22.1" },
]
[manifest.dependency-groups]
dev = [
{ name = "coverage", extras = ["toml"], specifier = "==7.15.4" },
{ name = "prek", specifier = "==0.4.14" },
+ { name = "pyfakefs", specifier = "==6.2.0" },
{ name = "pygobject-stubs", git = "https://github.com/pygobject/pygobject-stubs.git?rev=959c470767cd44a74912ba182e9fb202d0d96519" },
{ name = "pyright", extras = ["nodejs"], specifier = "==1.1.411" },
{ name = "pytest", specifier = "==9.1.1" },
+ { name = "pytest-asyncio", specifier = "==1.4.0" },
{ name = "pytest-cov", specifier = "==7.1.0" },
{ name = "pytest-mock", specifier = "==3.15.1" },
{ name = "pytest-xdist", specifier = "==3.8.0" },
{ name = "ruff", specifier = "==0.16.3" },
]
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
+ { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
+ { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
+ { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
+ { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
+ { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
+ { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
+ { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
+ { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
+ { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
+ { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
+ { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
+ { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
[[package]]
name = "batocera-bezel-overlay"
version = "44.0"
@@ -68,42 +165,38 @@ requires-dist = [
[[package]]
name = "batocera-configgen"
-version = "1.5"
+version = "44.0"
source = { editable = "package/batocera/core/batocera-configgen/configgen" }
dependencies = [
- { name = "batocera-common" },
+ { name = "batocera-launch" },
{ name = "configobj" },
{ name = "evdev", marker = "sys_platform == 'linux'" },
{ name = "ffmpeg-python" },
{ name = "pillow" },
{ name = "pysdl2" },
{ name = "pyudev", marker = "sys_platform == 'linux'" },
- { name = "pyyaml" },
{ name = "qrcode" },
{ name = "requests" },
- { name = "ruamel-yaml" },
{ name = "toml" },
]
[package.metadata]
requires-dist = [
- { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
{ name = "configobj" },
{ name = "evdev", marker = "sys_platform == 'linux'" },
{ name = "ffmpeg-python" },
{ name = "pillow" },
{ name = "pysdl2" },
{ name = "pyudev", marker = "sys_platform == 'linux'" },
- { name = "pyyaml" },
{ name = "qrcode" },
{ name = "requests" },
- { name = "ruamel-yaml" },
{ name = "toml" },
]
[[package]]
name = "batocera-es-system"
-version = "44.0.0"
+version = "44.0"
source = { editable = "python-src/batocera-es-system" }
dependencies = [
{ name = "batocera-common" },
@@ -120,6 +213,190 @@ requires-dist = [
{ name = "typing-extensions" },
]
+[[package]]
+name = "batocera-launch"
+version = "44.0"
+source = { editable = "python-src/batocera-launch" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "uvloop" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "uvloop" },
+]
+
+[[package]]
+name = "batocera-launch-cdogs"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-cdogs" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-cgenius"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-cgenius" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+ { name = "configobj" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+ { name = "configobj" },
+]
+
+[[package]]
+name = "batocera-launch-drastic"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-drastic" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-fallout"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-fallout" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-flycast"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-flycast" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-kodi"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-kodi" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-openjazz"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-openjazz" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-openjk"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-openjk" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-openjkdf2"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-openjkdf2" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-openmohaa"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-openmohaa" }
+dependencies = [
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
+[[package]]
+name = "batocera-launch-rpcs3"
+version = "44.0"
+source = { editable = "python-src/batocera-launch-rpcs3" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "batocera-common" },
+ { name = "batocera-launch" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiohttp" },
+ { name = "batocera-common", editable = "python-src/python-batocera-common" },
+ { name = "batocera-launch", editable = "python-src/batocera-launch" },
+]
+
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -237,6 +514,47 @@ dependencies = [
{ name = "future" },
]
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
[[package]]
name = "future"
version = "1.0.0"
@@ -264,6 +582,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
[[package]]
name = "nodeenv"
version = "1.10.0"
@@ -364,6 +727,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/37/e1/6fc64bb82e7270f61707e00b6d3154a4592ae0b2d3bd308173aa7aabe0a1/prek-0.4.14-py3-none-win_arm64.whl", hash = "sha256:ff588c02e10c8d05150763607671a22d5585c0ff7036c884d3489c2726eb215c", size = 5729906, upload-time = "2026-08-17T04:27:53.52Z" },
]
+[[package]]
+name = "propcache"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
+ { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
+ { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
+ { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
+ { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
+ { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
+]
+
[[package]]
name = "pycairo"
version = "1.29.0"
@@ -377,6 +783,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
]
+[[package]]
+name = "pyfakefs"
+version = "6.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/0d/c80012ee6e885c293ad63c5f5b049d3ef3fd2b32bbe6fa8739145f392ec6/pyfakefs-6.2.0.tar.gz", hash = "sha256:e59a36db447bf509ce9c97ab3d1510c08cc51895c5311325a560a5e5b5dc1940", size = 228273, upload-time = "2026-04-12T13:38:50.411Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/80/97571ac8295289c267367b7b60aadeae1a9a841e83f0a96ad9b65d1dd3c0/pyfakefs-6.2.0-py3-none-any.whl", hash = "sha256:0968a49db692694ffed420e54a9f1cbae4636637b880e8ab09c8ccc0f11bd7ae", size = 241113, upload-time = "2026-04-12T13:38:48.927Z" },
+]
+
[[package]]
name = "pygments"
version = "2.20.0"
@@ -447,6 +862,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
[[package]]
name = "pytest-cov"
version = "7.1.0"
@@ -626,3 +1053,71 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
+ { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
+ { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
+ { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
+ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.24.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
+ { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
+ { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
+ { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
+ { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
+ { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
+ { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
+ { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
+ { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
+ { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
+]