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" }, +]