From 1cd919352a0852274863dd1b89c73b472b884a3c Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Thu, 6 Aug 2026 18:19:11 +0200 Subject: [PATCH] wine: pure python generator - utils/wine.py holds what every wine system needs: runners and prefixes, building and importing into a prefix, autorun.cmd, saves, the filesystem a prefix sits on, and stopping whatever runs in a prefix. - the wine generator runs the games of any system itself, windows and windows_installers as much as mugen, each keeping its prefixes under wine-bottles///. It builds the command and the environment for every rom extension (.wine, .pc, .exe, .wsquashfs, .wtgz) and for the installers (.exe, .msi, .iso). An installation image is run through the installer its autorun.inf names, and only opens its drive in the file manager when it names none. - batocera-wine keeps what is a tool rather than a launch: tricks, createprefix, wine2squashfs, wine2winetgz, autorun/-list/-count and stop. --- .../configgen/configgen/emulatorlauncher.py | 22 +- .../configgen/generators/Generator.py | 18 + .../generators/wine/wineGenerator.py | 555 +++++- .../configgen/configgen/utils/wine.py | 1210 ++++++++++++- package/batocera/wine/batocera-wine/Config.in | 1 + .../batocera/wine/batocera-wine/batocera-wine | 1495 ++++------------- .../wine/batocera-wine/batocera-wine.mk | 3 + 7 files changed, 1989 insertions(+), 1315 deletions(-) diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py b/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py index bb2cad1c5b6..0a8fd1b17a0 100644 --- a/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py +++ b/package/batocera/core/batocera-configgen/configgen/configgen/emulatorlauncher.py @@ -59,8 +59,8 @@ def main(args: argparse.Namespace, maxnbplayers: int) -> int: original_rom = args.rom - # squashfs roms if squashed - if original_rom.suffix == ".squashfs": + # squashfs roms if squashed, the windows systems name theirs .wsquashfs + if original_rom.suffix in ('.squashfs', '.wsquashfs'): with mount_squashfs(original_rom) as squash_rom: return start_rom(args, maxnbplayers, squash_rom, original_rom) else: @@ -103,8 +103,8 @@ def start_rom(args: argparse.Namespace, maxnbplayers: int, rom: Path, original_r generator = get_generator(system.config.emulator, system.config.core) with ( - mount_overlayfs(rom, SAVES / system.name / original_rom.stem) - if original_rom.suffix == ".squashfs" and generator.writesToRom(system.config) + mount_overlayfs(rom, generator.writableRomDir(system, original_rom)) + if original_rom.suffix in ('.squashfs', '.wsquashfs') and generator.writesToRom(system.config) else contextlib.nullcontext(rom) ) as rom: # the resolution must be changed before configuration while the configuration may depend on it (ie bezels) @@ -184,10 +184,7 @@ def start_rom(args: argparse.Namespace, maxnbplayers: int, rom: Path, original_r # run the emulator _evmapy_instance = evmapy(systemName, system.config.emulator, effectiveCore, original_rom, player_controllers, guns) - with ( - _evmapy_instance, - set_hotkeygen_context(generator, system) - ): + with _evmapy_instance: # change directory if wanted executionDirectory = generator.executionDirectory(system.config, rom) if executionDirectory is not None: @@ -278,7 +275,14 @@ def start_rom(args: argparse.Namespace, maxnbplayers: int, rom: Path, original_r _logger.error("Failed to draw_gun_borders for gun_borders") _logger.error(e) - with profiler.pause(): + # the hotkeys are set once the generator has run: an emulator that + # exits through a command rather than a key only knows which one + # after it has decided how to run the game + with ( + set_hotkeygen_context(generator, system), + profiler.pause(), + generator.running(system.config, rom), + ): monitor_thread.start() exitCode = runCommand(cmd) diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/generators/Generator.py b/package/batocera/core/batocera-configgen/configgen/configgen/generators/Generator.py index 070ae8d3cbb..8d1a6ada252 100644 --- a/package/batocera/core/batocera-configgen/configgen/configgen/generators/Generator.py +++ b/package/batocera/core/batocera-configgen/configgen/configgen/generators/Generator.py @@ -1,10 +1,14 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod +from contextlib import nullcontext from typing import TYPE_CHECKING +from ..batoceraPaths import SAVES + if TYPE_CHECKING: from collections.abc import Mapping + from contextlib import AbstractContextManager from pathlib import Path from ..Command import Command @@ -38,10 +42,24 @@ def getMouseMode(self, config: SystemConfig, rom: Path) -> bool: def executionDirectory(self, config: SystemConfig, rom: Path) -> Path | None: return None + # Wraps the run of the command returned by generate(). Emulators that leave processes + # of their own behind (wine hands the game over to its wineserver and exits) override + # this to clean them up: the rom is only released for good once this exits, which + # matters for a rom mounted from a squashfs. + def running(self, config: SystemConfig, rom: Path) -> AbstractContextManager[None]: + return nullcontext() + # Some systems expect to write into the ROM area, for example: DOS, Amiga, and Wine def writesToRom(self, config: SystemConfig) -> bool: return False + # Where the writes of a squashed rom are kept, when writesToRom() asks for an + # overlay. They go with the saves of the system, unless the emulator already has a + # place of its own for the state of a game: wine keeps a prefix per runner, and a + # squashed rom is a prefix. + def writableRomDir(self, system: Emulator, rom: Path) -> Path: + return SAVES / system.name / rom.stem + # mame or libretro have internal bezels, don't display the one of mangohud def supportsInternalBezels(self) -> bool: return False diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py b/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py index 5c92e579e4a..48a54a94dea 100644 --- a/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py +++ b/package/batocera/core/batocera-configgen/configgen/configgen/generators/wine/wineGenerator.py @@ -1,71 +1,530 @@ from __future__ import annotations +import logging import os +import re +import resource +import shlex import subprocess +import tarfile +from contextlib import ExitStack, contextmanager +from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from ... import Command +from ...batoceraPaths import BATOCERA_CONF, CACHE, ROMS, SAVES from ...controller import generate_sdl_game_controller_config from ...exceptions import BatoceraException +from ...settings.unixSettings import UnixSettings +from ...utils import wine from ..Generator import Generator if TYPE_CHECKING: - from ...types import HotkeysContext + from collections.abc import Generator as Iterator + + from ...config import SystemConfig + from ...types import HotkeysContext, Resolution + +_logger = logging.getLogger(__name__) + +# the system an installed game belongs to, and the one whose prefixes and settings the +# installers share: what windows_installers installs is a windows game +_WINDOWS: Final = 'windows' +_WINDOWS_INSTALLERS: Final = 'windows_installers' + +# roms that are a file rather than a directory, and run from where they sit: the +# installers, and the bare .exe a windows rom may be +_FILE_SUFFIXES: Final = ('.exe', '.iso', '.msi') + +_NVIDIA_PRIME: Final = Path('/var/tmp/nvidia.prime') +_NVIDIA_CACHE: Final = CACHE / 'nvidia' class WineGenerator(Generator): + """ + Runs a game through wine, in the shapes batocera-wine established for them. Any + system may ask for it, mugen as much as windows, and each keeps its prefixes under + /userdata/system/wine-bottles/// as batocera-wine did: + + - a .wine rom is a complete wine prefix and runs in itself + - a .wsquashfs is that prefix squashed, mounted with a writable overlay kept in + /userdata/system/wine-bottles, so what reaches generate() is a prefix like any + other + - a .wtgz is that prefix as a tarball, unpacked once into a prefix of its own + - a .pc rom is a directory of game files, and a bare .exe a single one, both run in + a prefix of their own under /userdata/system/wine-bottles + - a windows_installers rom is an installer, run in a new prefix in + /userdata/roms/windows that becomes the installed game + + A rom says what to run, and how, in its autorun.cmd, see utils/wine.py. + """ + + def __init__(self) -> None: + # what has to stay mounted while the game runs, released by running() + self.__resources = ExitStack() + self.__runner: wine.Runner | None = None + self.__installing = False + # the system whose bottles the game runs in: any system may run its games through + # wine, and each keeps prefixes of its own. Every call that builds one is given + # the system first, this is only what it is until one of them has run + self.__system = _WINDOWS + + # a wine prefix is written to as the game runs, so a squashed rom needs the overlay + def writesToRom(self, config: SystemConfig) -> bool: + return True + + def writableRomDir(self, system, rom: Path) -> Path: + # a squashed rom is a prefix, and what it writes is prefix rather than saves: + # it belongs where the prefix of any other rom lives, kept per runner so that + # changing runner doesn't reuse what another one wrote. what the game saves is + # linked out to /userdata/saves by the SAVEDIR of its autorun.cmd + self.__system = system.name + + return wine.get_prefix_path(system.name, wine.get_runner_name(_runner_name(system.config)), rom) def getHotkeysContext(self) -> HotkeysContext: + # closing the wineserver of the prefix is what closes the game, and the prefix + # is the one generate() picked: the hotkeys are set once it has run + if (runner := self.__runner) is None: + raise BatoceraException('The wine hotkeys are only known once the game has been prepared') + return { "name": "wine", - "keys": { "exit": "/usr/bin/batocera-wine windows stop" } + "keys": { "exit": f"WINEPREFIX={shlex.quote(str(runner.bottle_dir))} {shlex.quote(str(runner.wineserver))} -k" } } - def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): - if system.name == "windows_installers": - commandArray = ["batocera-wine", "windows", "install", rom] - return Command.Command(array=commandArray) - - if system.name == "windows": - commandArray = ["batocera-wine", "windows", "play", rom] - - environment: dict[str, str | Path] = {} - #system.language - try: - language = subprocess.check_output("batocera-settings-get system.language", shell=True, text=True).strip() - except subprocess.CalledProcessError: - language = 'en_US' - if language: - environment.update({ - "LANG": language + ".UTF-8", - "LC_ALL": language + ".UTF-8" - } - ) - # sdl controller option - default is on - if system.config.get_bool("sdl_config", True): - environment.update( - { - "SDL_GAMECONTROLLERCONFIG": generate_sdl_game_controller_config(playersControllers), - "SDL_JOYSTICK_HIDAPI": "0" - } - ) - # ensure nvidia driver used for vulkan - if Path('/var/tmp/nvidia.prime').exists(): - variables_to_remove = ['__NV_PRIME_RENDER_OFFLOAD', '__VK_LAYER_NV_optimus', '__GLX_VENDOR_LIBRARY_NAME'] - for variable_name in variables_to_remove: - if variable_name in os.environ: - del os.environ[variable_name] - - environment.update( - { - 'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json:/usr/share/vulkan/icd.d/nvidia_icd.i686.json', - } - ) - - return Command.Command(array=commandArray, env=environment) - - raise BatoceraException("Invalid system: " + system.name) - def getMouseMode(self, config, rom): return config.get_bool('force_mouse') + + def executionDirectory(self, config, rom): + # an installer, or a game that is a single executable, runs from where it sits + if rom.suffix.lower() in _FILE_SUFFIXES: + return rom.parent + + return wine.get_game_dir(self.__rom_dir(config, rom)) + + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + self.__system = system.name + self.__installing = system.name == _WINDOWS_INSTALLERS + + try: + if self.__installing: + return self.__install(system.config, rom, playersControllers) + + return self.__play(system, rom, playersControllers, gameResolution) + except BaseException: + # the game won't start, so running() won't be there to release them + self.__resources.close() + raise + + @contextmanager + def running(self, config, rom) -> Iterator[None]: + with self.__resources: + if (runner := self.__runner) is None: + yield + return + + # the rom is unmounted the moment this returns, so nothing may still be + # holding it: wine hands the game over to the wineserver and exits first + with runner.running(): + yield + + if self.__installing: + self.__installed(runner) + + def __prepare(self, config: SystemConfig, rom: Path, /, *, prefix: Path | None = None) -> wine.Runner: + """ + The runner and the prefix the rom runs in, built if it doesn't exist yet. Both + executionDirectory() and generate() need them, and executionDirectory() is + called first, so this is done once and kept. Whichever gets here first has told + us the system, whose bottles the prefix is one of. + """ + if self.__runner is not None: + return self.__runner + + wanted_runner = _runner_name(config) + runner_name = wine.get_runner_name(wanted_runner) + + if prefix is None: + prefix = wine.get_prefix_path(self.__system, runner_name, rom) + + runner = wine.Runner.from_prefix( + wanted_runner, + prefix, + arch=wine.wanted_prefix_arch(runner_name, enable_win32=config.get_bool('enable_win32')), + ) + self.__runner = runner + + _logger.debug('running %s in %s with %s', rom.name, prefix, runner.runner_name) + + wine.log_filesystems(rom, prefix) + + built = (prefix / 'system.reg').exists() + runner.create_bottle() + + # a .wtgz is a prefix in a tarball, unpacked into the prefix built for it + if not built and rom.suffix.lower() == '.wtgz': + _logger.info('unpacking %s into %s', rom, prefix) + with tarfile.open(rom) as tar: + tar.extractall(prefix, filter='tar') + + return runner + + def __rom_dir(self, config: SystemConfig, rom: Path, /) -> Path: + """ + Where the autorun.cmd of the game is: a rom that is a directory holds it, whether + it keeps the game files next to its own as a .pc rom does or is a prefix itself + as a .wine rom and a mounted .wsquashfs are. Only an archive describes itself in + the prefix it is unpacked into, which is the one thing here worth building. + """ + if rom.is_dir(): + return rom + + return self.__prepare(config, rom).bottle_dir + + def __play(self, system, rom: Path, playersControllers, gameResolution: Resolution, /) -> Command.Command: + config: SystemConfig = system.config + runner = self.__prepare(config, rom) + + self.__setup_prefix(config, runner) + + environment = self.__environment(config, runner, playersControllers) + + command: list[str | Path] = [runner.wine] + + # a virtual desktop puts the game in a window of its own, at the size it expects, + # on a black background rather than wine's blue + if config.get_bool('virtual_desktop'): + runner.set_registry_value(r'HKEY_CURRENT_USER\Control Panel\Colors', 'Background', 'REG_SZ', '0 0 0') + command += ['explorer', f'/desktop=Wine,{gameResolution["width"]}x{gameResolution["height"]}'] + + if rom.is_file() and rom.suffix.lower() == '.exe': + # a bare .exe is the game, and runs from the directory it sits in + command.append(rom.name) + return Command.Command(array=command, env=environment) + + rom_dir = self.__rom_dir(config, rom) + + # what the game saves goes to /userdata/saves, if it says where it saves it + wine.link_saves(rom_dir, runner.bottle_dir, SAVES / system.name / rom.stem) + + try: + exe, arguments = wine.get_game_command(rom_dir) + except BatoceraException as e: + # what batocera-wine did without an autorun.cmd: open the prefix and let + # the user start the game themselves + _logger.warning('%s, starting the file manager instead', e) + command.append('explorer') + else: + command.append(exe) + command += arguments + + environment.update(wine.get_autorun_environment(rom_dir)) + + return Command.Command(array=command, env=environment) + + def __install(self, config: SystemConfig, rom: Path, playersControllers, /) -> Command.Command: + # the installer builds the game, as a prefix in the roms of the windows system + runner = self.__prepare(config, rom, prefix=ROMS / _WINDOWS / _installed_name(rom)) + + # and the game keeps the options that were chosen for its installer + _copy_installer_settings(rom.name, runner.bottle_dir.name) + + self.__setup_prefix(config, runner) + + environment = self.__environment(config, runner, playersControllers) + + command: list[str | Path] + + match rom.suffix.lower(): + case '.exe': + command = [runner.wine, rom] + case '.msi': + command = [runner.msiexec, '-i', rom] + case '.iso': + # the image is the installation media, mounted as the d: drive + mount_point = self.__resources.enter_context(wine.mount_iso(rom)) + + drive = runner.bottle_dir / 'dosdevices' / 'd:' + drive.parent.mkdir(parents=True, exist_ok=True) + + if drive.is_symlink() or drive.exists(): + drive.unlink() + drive.symlink_to(mount_point) + + if (installer := wine.autorun_inf_command(mount_point)) is not None: + _logger.info('the autorun.inf of %s runs %s', rom.name, ' '.join(installer)) + command = [runner.wine, *installer] + else: + _logger.info('%s names no installer to run, opening its drive instead', rom.name) + command = [runner.wine, 'explorer', 'd:'] + case suffix: + raise BatoceraException(f'Unknown installer type: {suffix}') + + return Command.Command(array=command, env=environment) + + def __installed(self, runner: wine.Runner, /) -> None: + """Turn the prefix an installer wrote into a game the windows system can run.""" + prefix = runner.bottle_dir + + # the image is unmounted, and a link to it would only confuse the next start + drive = prefix / 'dosdevices' / 'd:' + if drive.is_symlink(): + drive.unlink() + + if not (prefix / 'system.reg').exists(): + _logger.warning('%s was not created, nothing was installed', prefix) + return + + executables = wine.find_game_executables(prefix) + + if len(executables) != 1: + _logger.info( + '%s executables found in %s, leaving its autorun.cmd to be filled in', + len(executables), prefix, + ) + + wine.write_autorun_cmd(prefix, executables[0] if len(executables) == 1 else None) + + def __setup_prefix(self, config: SystemConfig, runner: wine.Runner, /) -> None: + """What a prefix is given before the game runs, whether it is new or not.""" + runner.install_redists() + runner.install_msis() + runner.install_rawinput() + runner.install_regs() + runner.install_fonts() + runner.sandbox_prefix() + runner.set_hidraw(config.get_bool('enable_hidraw')) + + def __environment( + self, + config: SystemConfig, + runner: wine.Runner, + playersControllers, + /, + ) -> dict[str, str | Path]: + environment = runner.get_environment() + environment.update(_wine_options(config)) + environment.update(_display_environment(config)) + environment.update(_dxvk_environment(config, runner)) + + if language := config.get_str('system.language', ''): + environment.update({ + "LANG": f'{language}.UTF-8', + "LC_ALL": f'{language}.UTF-8', + }) + + # sdl controller option - default is on + if config.get_bool('sdl_config', True): + environment.update({ + "SDL_GAMECONTROLLERCONFIG": generate_sdl_game_controller_config(playersControllers), + "SDL_JOYSTICK_HIDAPI": "0", + }) + + # ensure nvidia driver used for vulkan + if _NVIDIA_PRIME.exists(): + for variable_name in ('__NV_PRIME_RENDER_OFFLOAD', '__VK_LAYER_NV_optimus', '__GLX_VENDOR_LIBRARY_NAME'): + os.environ.pop(variable_name, None) + + environment.update({ + 'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.x86_64.json:/usr/share/vulkan/icd.d/nvidia_icd.i686.json', + }) + + return environment + + +def _runner_name(config: SystemConfig, /) -> str: + # the runner chosen for the game, falling back to the core, which is what the + # runner was called before it had an option of its own + return config.get_str('wine-runner', '') or config.get_str('core', '') + + +def _installed_name(rom: Path, /) -> str: + # the time keeps a game installed twice from landing on the prefix of the first one + return f'{datetime.now():%y%m%d-%H%M%S}_{rom.stem}.wine' + + +def _display_environment(config: SystemConfig, /) -> dict[str, str | Path]: + """ + Wine draws through XWayland unless the game asks for the native wayland driver, in + which case it is given no DISPLAY at all and picks the wayland driver itself. + """ + try: + display_mode = subprocess.run( + ['batocera-resolution', 'getDisplayMode'], capture_output=True, text=True, timeout=5, check=False, + ).stdout.strip() + except (OSError, subprocess.TimeoutExpired) as e: + _logger.warning('unable to tell what the display is running: %s', e) + display_mode = '' + + if display_mode == 'wayland' and config.get_str('wayland_driver', 'xwayland') == 'native': + _logger.debug('wayland with the native driver requested, running without a DISPLAY') + return {'DISPLAY': ''} + + # x11, or xwayland: give the game the keyboard layout of the system + if keyboard := _system_setting('system.kblayout'): + _run(['setxkbmap', keyboard]) + + return {} + + +def _wine_options(config: SystemConfig, /) -> dict[str, str | Path]: + """The wine and driver knobs the windows systems expose, as their environment.""" + debug = config.get_bool('wine_debug') + + _NVIDIA_CACHE.mkdir(parents=True, exist_ok=True) + + environment: dict[str, str | Path] = { + 'WINEDEBUG': 'err+all,fixme+all' if debug else '-all', + 'PBA_ENABLE': config.get_bool('pba', return_values=('1', '0')), + 'DXVK_FRAME_RATE': config.get_bool('fps_limit', return_values=('60', '0')), + 'WINE_ENABLE_HIDRAW': config.get_bool('enable_hidraw', return_values=('1', '0')), + # Wine-mono override for FNA games + 'WINE_MONO_OVERRIDES': 'Microsoft.Xna.Framework.*,Gac=n', + # Disable XIM support until libx11 >= 1.7 is widespread + 'WINE_ALLOW_XIM': config.get_bool('allow_xim', return_values=('1', '0')), + # Advanced options from proton + 'WINE_DISABLE_WRITE_WATCH': config.get_bool('no_write_watch', return_values=('1', '0')), + 'WINE_LARGE_ADDRESS_AWARE': config.get_bool('force_large_adress', return_values=('1', '0')), + 'WINE_HEAP_DELAY_FREE': config.get_bool('heap_delay_free', return_values=('1', '0')), + 'WINE_HIDE_NVIDIA_GPU': config.get_bool('hide_nvidia_gpu', return_values=('1', '0')), + 'NTFS_MODE': config.get_bool('wine_ntfs', return_values=('1', '0')), + 'STAGING_SHARED_MEMORY': '1', + 'USE_BUILTIN_VKD3D': '0', + # the shader caches of wine and of the drivers, all under /userdata/system/cache + 'XDG_CACHE_HOME': CACHE, + 'VKD3D_SHADER_CACHE_PATH': CACHE, + # Nvidia variables + '__GL_SHADER_DISK_CACHE_SIZE': '2147483648', + '__GL_SHADER_DISK_CACHE_SKIP_CLEANUP': '1', + '__GL_SHADER_DISK_CACHE_PATH': _NVIDIA_CACHE, + } + + # the debug variables are read by whether they are set at all, not by their value + if not debug: + environment['DXVK_LOG_LEVEL'] = 'none' + environment['VKD3D_DEBUG'] = 'none' + + # so is fsr: leaving it out is what turns it on + if not config.get_bool('fsr'): + environment['WINE_FULLSCREEN_FSR'] = '0' + + # ESYNC and FSYNC are deprecated in favor of NTSYNC with modern Wine and kernel. + # NTSYNC is enabled by default when supported, the option is there to turn it off. + environment['WINEDISABLEFASTSYNC'] = '0' if _setup_ntsync(config.get_bool('ntsync', True)) else '1' + + # While esync is deprecated, the ulimit is still important for Wine performance. + _raise_file_limit() + + return environment + + +def _dxvk_environment(config: SystemConfig, runner: wine.Runner, /) -> dict[str, str | Path]: + """ + Install the direct3d dlls the game is to draw through, and tell wine to load them. + dxvk translates direct3d to vulkan, and is only built on the architectures it + supports; without it, the game runs on the wined3d dlls wine ships itself. + """ + nvapi = config.get_bool('enable_nvapi') or config.get_bool('enable_vkreflex') + + environment: dict[str, str | Path] = { + 'DXVK_STATE_CACHE': 'reset' if config.get_bool('dxvk_reset_cache') else '1', + 'DXVK_ENABLE_NVAPI': '1' if nvapi else '0', + 'NVAPI': '1' if nvapi else '0', + } + + # Reflex requires NVAPI to function; force it on if Reflex is enabled + if config.get_bool('enable_vkreflex'): + environment['DXVK_NVAPI_VKREFLEX'] = '1' + else: + environment['DISABLE_DXVK_NVAPI_VKREFLEX'] = '1' + + if config.get_bool('dxvk_hud'): + environment['DXVK_HUD'] = '1' + + # no start menu entries wanted, batocera is what starts the games + dll_overrides = ['winemenubuilder.exe='] + + if config.get_bool('dxvk') and (dxvk_dlls := runner.install_dxvk()): + environment.update({ + 'DXVK_ASYNC': '1', + 'DXVK_CONFIG_FILE': wine.USER_WINE_DIR / 'dxvk.conf', + 'DXVK_STATE_CACHE_PATH': CACHE, + }) + + if graphics := sorted(dll for dll in dxvk_dlls if not dll.startswith(('nvapi', 'nvofapi'))): + dll_overrides.append(f"{','.join(graphics)}=n") + + if nvapi_dlls := sorted(dll for dll in dxvk_dlls if dll.startswith(('nvapi', 'nvofapi'))): + dll_overrides.append(f"{','.join(nvapi_dlls)}={'n' if nvapi else ''}") + else: + environment['DXVK_ASYNC'] = '0' + + # a prefix that ran with dxvk still links into it, put wine's own dlls back + if builtin := sorted(runner.install_builtin_d3d()): + dll_overrides.append(f"{','.join(builtin)}=b") + + dll_overrides.append('nvapi64,nvapi=') + + environment['WINEDLLOVERRIDES'] = ';'.join(dll_overrides) + + return environment + + +def _run(command: list[str], /) -> None: + """Run a system tool, none of which is worth failing a game start over.""" + try: + subprocess.run(command, capture_output=True, check=False) + except OSError as e: + _logger.warning('%s failed: %s', command[0], e) + + +def _setup_ntsync(wanted: bool, /) -> bool: + """Load or unload the ntsync module, and say whether wine may use it.""" + if not wanted: + _run(['rmmod', 'ntsync']) + _logger.debug('ntsync disabled for wine') + return False + + _run(['modprobe', 'ntsync']) + + if os.access('/dev/ntsync', os.R_OK): + return True + + _logger.warning('/dev/ntsync is not accessible, disabling ntsync in wine') + return False + + +def _raise_file_limit() -> None: + wanted = 1048576 + + try: + _, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + resource.setrlimit(resource.RLIMIT_NOFILE, (min(wanted, hard), hard)) + except (OSError, ValueError) as e: + _logger.warning('could not raise the file limit, which may impact performance: %s', e) + + +def _system_setting(name: str, /) -> str | None: + # a system-wide setting, which isn't one of the options of a system + return UnixSettings(BATOCERA_CONF).config.get('DEFAULT', name, fallback=None) + + +def _copy_installer_settings(installer_name: str, installed_name: str, /) -> None: + """ + Carry the options chosen for an installer over to the game it installs, so that the + runner and the tweaks it was installed with are the ones it runs with. + """ + settings = UnixSettings(BATOCERA_CONF) + wanted = re.compile(rf'^{_WINDOWS_INSTALLERS}(\["{re.escape(installer_name)}"\])?\.') + + for key, value in list(settings.config.items('DEFAULT')): + if not wanted.match(key): + continue + + installed_key = key.replace(_WINDOWS_INSTALLERS, _WINDOWS, 1).replace(installer_name, installed_name, 1) + + _logger.debug('%s -> %s = %s', key, installed_key, value) + + _run(['batocera-settings-set', installed_key, value]) diff --git a/package/batocera/core/batocera-configgen/configgen/configgen/utils/wine.py b/package/batocera/core/batocera-configgen/configgen/configgen/utils/wine.py index ac4a071d424..46794164d57 100644 --- a/package/batocera/core/batocera-configgen/configgen/configgen/utils/wine.py +++ b/package/batocera/core/batocera-configgen/configgen/configgen/utils/wine.py @@ -2,58 +2,762 @@ import logging import os +import re +import shlex +import shutil +import signal import subprocess +import time +from contextlib import contextmanager from dataclasses import InitVar, dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Final, Literal, Self +from datetime import datetime +from pathlib import Path, PureWindowsPath +from typing import TYPE_CHECKING, Final, Self -from ..batoceraPaths import HOME +from ..batoceraPaths import HOME, ROMS, SAVES, mkdir_if_not_exists +from ..exceptions import BatoceraException if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Generator, Iterable, Mapping, Sequence _logger = logging.getLogger(__name__) WINE_BASE: Final = Path('/usr/wine') +# where the user drops things of their own: runners, a dxvk build, dlls to import +USER_WINE_DIR: Final = HOME / 'wine' +# a runner unpacked here is picked by name, as it was for batocera-wine +_CUSTOM_RUNNERS: Final = USER_WINE_DIR / 'custom' + +WINE_BOTTLES: Final = HOME / 'wine-bottles' + _WINETRICKS: Final = WINE_BASE / 'winetricks' -_WINE_BOTTLES: Final = HOME / 'wine-bottles' +# where a session leaves its socket, as getLocalXDisplay and getLocalWaylandDisplay +# look for them +_X_SOCKETS: Final = Path('/tmp/.X11-unix') +_WAYLAND_RUNTIME_DIR: Final = Path('/run') +# the display batocera runs on, when no session is to be found at all +_DEFAULT_DISPLAY: Final = ':0.0' +_BSOD: Final = Path('/usr/bin/bsod-wine') +_DXVK: Final = WINE_BASE / 'dxvk' +# a dxvk unpacked here replaces the shipped one, as it did for batocera-wine +_USER_DXVK: Final = USER_WINE_DIR / 'dxvk' + +# the windows directory each dxvk build belongs in +_DXVK_ARCHS: Final = (('x64', 'system32'), ('x32', 'syswow64')) +# the same, for the builtin dlls wine ships, used when dxvk is turned off +_BUILTIN_ARCHS: Final = (('x86_64-windows', 'system32'), ('i386-windows', 'syswow64')) +# the direct3d dlls a prefix is given, whether they come from dxvk or from wine +_D3D_DLLS: Final = ('d3d8', 'd3d9', 'd3d10core', 'd3d11', 'd3d12', 'd3d12core', 'dxgi') + +# how long to give the wineserver to shut its clients down on its own, before killing it +_WINESERVER_WAIT_TIMEOUT: Final = 10 + +# how many times, and how far apart, to kill whatever is still holding the prefix +_PREFIX_KILL_ATTEMPTS: Final = 10 +_PREFIX_KILL_INTERVAL: Final = 0.2 + +_PROC: Final = Path('/proc') + +_MOUNTINFO: Final = _PROC / 'self' / 'mountinfo' +_MOUNT_ESCAPE: Final = re.compile(r'\\(\d{3})') + +_AUTORUN_INF: Final = 'autorun.inf' +_AUTORUN_SECTION: Final = re.compile(r'\[\s*autorun(?:\.(?P[^\]\s]+))?\s*\]', re.IGNORECASE) +_AUTORUN_KEYS: Final = ('open', 'shellexecute') +_AUTORUN_ARCH: Final = 'amd64' +_UTF16_BOMS: Final = (b'\xff\xfe', b'\xfe\xff') + +# what wine writes at the top of the registry files it creates in a prefix +_ARCH_IN_REGISTRY: Final = re.compile(r'^#arch=(\S+)', re.MULTILINE) + +_DEFAULT_WINE_RUNNER: Final = 'wine-tkg' + +# what has been installed into a prefix from the import directories of USER_WINE_DIR +_IMPORT_LOG: Final = USER_WINE_DIR / 'imports.log' + +# a registry file dropped here is imported into the prefix ahead of the ones of the +# user, as batocera-wine did. It held the raw input setting when batocera-wine wrote it +# itself, and is now whatever put it there wants of the prefix +_RAWINPUT_REG: Final = Path('/var/run/rawinput.reg') + + +def local_display() -> dict[str, str]: + """ + The session batocera is running, as the environment to reach it with, for a tool + called from a shell that has none of its own such as an ssh login. + + X comes first, which is also what a wayland session offers through xwayland, and the + wayland socket is used when there is no X at all: wine draws through xwayland unless + it is asked for its own wayland driver, which is what having only WAYLAND_DISPLAY + tells it to do. + """ + # the same sockets getLocalXDisplay and getLocalWaylandDisplay pick the first of + if socket := next(iter(sorted(_X_SOCKETS.glob('X[0-9]*'))), None): + return {'DISPLAY': f':{socket.name[1:]}'} + + if lock := next(iter(sorted(_WAYLAND_RUNTIME_DIR.glob('wayland-*.lock'))), None): + # the compositor's socket sits in XDG_RUNTIME_DIR, which an ssh login is without + return {'WAYLAND_DISPLAY': lock.stem, 'XDG_RUNTIME_DIR': str(_WAYLAND_RUNTIME_DIR)} + + _logger.debug('no X or wayland session found, falling back to %s', _DEFAULT_DISPLAY) + + return {'DISPLAY': _DEFAULT_DISPLAY} + + +def _mount_table() -> list[tuple[Path, str, str]]: + """Where the system has something mounted, deepest mount first, each with the + filesystem it holds and what it was mounted from.""" + mounts: list[tuple[Path, str, str]] = [] + + try: + table = _MOUNTINFO.read_text(encoding='utf-8', errors='replace') + except OSError as e: + _logger.debug('unable to read %s: %s', _MOUNTINFO, e) + return mounts + + for line in table.splitlines(): + mount, separator, filesystem = line.partition(' - ') + + if not separator: + continue + + mount_fields = mount.split(' ') + filesystem_fields = filesystem.split(' ') + + if len(mount_fields) < 5 or len(filesystem_fields) < 2: + continue + + mounts.append(( + Path(_unescape_mount(mount_fields[4])), + filesystem_fields[0], + _unescape_mount(filesystem_fields[1]), + )) + + return sorted(mounts, key=lambda mount: len(mount[0].parts), reverse=True) + + +def _unescape_mount(field: str, /) -> str: + return _MOUNT_ESCAPE.sub(lambda escape: chr(int(escape.group(1), 8)), field) + + +def filesystem_of(path: Path, /) -> tuple[str, Path, str] | None: + """ + The filesystem a path lives on, as what it is, where it is mounted and what it was + mounted from. A path that isn't there yet is the filesystem it would be created on. + """ + existing = path + + while not existing.exists() and existing != existing.parent: + existing = existing.parent + + try: + resolved = existing.resolve() + except OSError: + resolved = existing + + for mount_point, filesystem, source in _mount_table(): + if resolved == mount_point or mount_point in resolved.parents: + return filesystem, mount_point, source + + return None + + +def log_filesystems(*paths: Path) -> None: + """ + Report what each of these is and what filesystem it lives on. A wine prefix is built + out of symlinks and expects the permissions and the case of a linux filesystem, so + where it sits is the first thing to know about a game that doesn't run. batocera-wine + printed this as requestFileSystem, and it is here for the same reason. + """ + for path in dict.fromkeys(paths): + if path.is_symlink() and not path.exists(): + _logger.info('%s is a link to %s, which is not there', path, path.readlink()) + continue + + if (mount := filesystem_of(path)) is None: + where = 'an unknown filesystem' + else: + filesystem, mount_point, source = mount + where = f'{filesystem}, {source} mounted on {mount_point}' + + if not path.exists(): + _logger.info('%s is not there yet, and belongs on %s', path, where) + continue + + link = f', linked to {path.readlink()}' if path.is_symlink() else '' + kind = 'directory' if path.is_dir() else 'file' + + _logger.info('%s %s is on %s%s', kind, path, where, link) + + +def get_runner_name(name: str, /) -> str: + """The runner a name ends up meaning: the default one when what was asked for isn't + installed, and what the prefix of a game is kept under.""" + if not name: + return _DEFAULT_WINE_RUNNER + + # names batocera-wine accepted for the two shipped runners + name = {'lutris': 'wine-tkg', 'proton': 'wine-proton'}.get(name, name) + + if any((base / name).is_dir() for base in (WINE_BASE, _CUSTOM_RUNNERS)): + return name + + _logger.warning("wine runner %s isn't installed, falling back to %s", name, _DEFAULT_WINE_RUNNER) + + return _DEFAULT_WINE_RUNNER + + +def get_runner_path(name: str, /) -> Path: + """The directory a runner lives in: one batocera ships, or one the user unpacked into + /userdata/system/wine/custom. The shipped one wins when both carry the name.""" + runner_name = get_runner_name(name) + + for base in (WINE_BASE, _CUSTOM_RUNNERS): + if (path := base / runner_name).is_dir(): + return path + + return WINE_BASE / runner_name + + +def get_prefix_path(system: str, runner_name: str, rom: Path, /) -> Path: + """ + Where the prefix of a rom lives. A .wine rom is a prefix already and runs in + itself, anything else (a .pc directory, a bare .exe, an archive) is given one of + its own, kept per runner so that changing runner doesn't reuse a prefix built by + another one. + """ + # a .wsquashfs reaches us mounted, under a name that no longer says what it is, so + # a rom that holds a built prefix is taken for one whatever it is called + if rom.is_dir() and (rom.suffix.lower() == '.wine' or (rom / 'system.reg').is_file()): + return rom + + return WINE_BOTTLES / system / runner_name / f'{rom.name}.wine' + + +def current_prefix_arch(prefix: Path, /) -> str | None: + """The architecture wine recorded in a prefix, None if it hasn't been built yet.""" + try: + registry = (prefix / 'userdef.reg').read_text(encoding='utf-8', errors='replace') + except OSError: + return None + + if match := _ARCH_IN_REGISTRY.search(registry): + return match.group(1) + + return None + + +def wanted_prefix_arch(runner_name: str, /, *, enable_win32: bool = False) -> str | None: + """ + win32 when anything asks for a 32bit prefix, which only means something while the + prefix doesn't exist: a prefix keeps the architecture it was built with, and Runner + is what reconciles this with one that is already there. + + runner_name is the runner as it resolves, so that asking for a runner that isn't + installed doesn't ask for its architecture too. + """ + if enable_win32: + _logger.info('a 32bit prefix is asked for, enable_win32 is on') + return 'win32' + + # a runner named win32-something builds 32bit prefixes, as it did for batocera-wine + if re.match(r'^win32[-_.]', runner_name, re.IGNORECASE): + _logger.info('a 32bit prefix is asked for, the runner %s carries win32 in its name', runner_name) + return 'win32' + + return None + + +def get_autorun_vars(rom: Path, /) -> dict[str, str]: + # roms built for the batocera-wine era describe themselves in an autorun.cmd, + # holding the CMD= to run, the DIR= to run it from, and LANG=/ENV= overrides + autorun = rom / "autorun.cmd" + + if not autorun.is_file(): + return {} + + variables: dict[str, str] = {} + + for line in autorun.read_text(encoding="utf-8-sig", errors="replace").splitlines(): + key, _, value = line.partition('=') + key = key.strip().upper() + value = value.strip().strip('"') + # like batocera-wine did, the first value of a key wins + if key and value and key not in variables: + variables[key] = value + + return variables + +def resolve_in_rom(rom: Path, name: str, /) -> Path: + # autorun.cmd holds a windows path, PureWindowsPath takes the separators apart + return rom.joinpath(*PureWindowsPath(name).parts) + +def get_game_dir(rom: Path, /) -> Path: + # DIR= is the directory the game runs from, and CMD= is relative to it + if game_dir := get_autorun_vars(rom).get('DIR'): + if (resolved := resolve_in_rom(rom, game_dir)).is_dir(): + return resolved + + _logger.warning("%s names the directory %s, which doesn't exist", rom / 'autorun.cmd', game_dir) + + return rom + +def get_game_command(rom: Path, /) -> tuple[Path, list[str]]: + """ + What the rom says to run, as the executable and the arguments to pass it. The rom + names it in its autorun.cmd, we don't guess: a game is free to rename its engine, + and the other executables next to it are installers and tools. + """ + autorun = rom / "autorun.cmd" + + if not (cmd := get_autorun_vars(rom).get('CMD')): + raise BatoceraException(f"{autorun} doesn't exist or doesn't name the CMD to run") + + game_dir = get_game_dir(rom) + + # a CMD is usually just the executable, and may hold spaces without being quoted, + # so take it whole first and only split it when that names nothing that exists + if (exe := resolve_in_rom(game_dir, cmd)).is_file(): + return exe, [] + + # posix=False keeps the backslashes of a windows path, at the cost of leaving the + # quotes on the tokens + tokens = [token.strip('"') for token in shlex.split(cmd, posix=False)] + + if tokens and (exe := resolve_in_rom(game_dir, tokens[0])).is_file(): + return exe, tokens[1:] + + raise BatoceraException(f"The executable {cmd} named by {autorun} doesn't exist in {game_dir}") + +def get_game_exe(rom: Path, /) -> Path: + exe, _ = get_game_command(rom) + + _logger.debug("executable %s from autorun.cmd", exe) + + return exe + +def get_autorun_environment(rom: Path, /) -> dict[str, str]: + """ + The environment a rom asks for in its autorun.cmd: LANG= is the locale to run the + game in, and ENV= a list of variables, as batocera-wine passed them to the shell. + """ + variables = get_autorun_vars(rom) + environment: dict[str, str] = {} + + if lang := variables.get('LANG'): + environment['LC_ALL'] = lang + + for assignment in shlex.split(variables.get('ENV', '')): + name, separator, value = assignment.partition('=') + if separator: + environment[name] = value + + return environment + + +def link_saves(rom_dir: Path, prefix: Path, system_saves: Path, /) -> None: + """ + Keep what the game saves in /userdata/saves rather than inside the prefix, so that + it survives the prefix being rebuilt and is backed up with the rest of the saves. + The rom asks for it in its autorun.cmd, with either a SAVEDIR= directory or a + SAVEFILES= list of files, both relative to the prefix. + """ + variables = get_autorun_vars(rom_dir) + save_dir = variables.get('SAVEDIR') + save_files = variables.get('SAVEFILES') + + if save_dir: + mkdir_if_not_exists(system_saves) + + target = resolve_in_rom(prefix, save_dir) + log_filesystems(system_saves, target) + + if target.is_symlink(): + return + + # the prefix already holds saves, they move to /userdata/saves once + if target.is_dir(): + shutil.copytree(target, system_saves, dirs_exist_ok=True) + shutil.rmtree(target) + + mkdir_if_not_exists(target.parent) + target.symlink_to(system_saves) + _logger.debug('saves: %s -> %s', target, system_saves) + return + + if not save_files: + return + + mkdir_if_not_exists(system_saves) + log_filesystems(system_saves, resolve_in_rom(prefix, save_files.partition(';')[0].strip())) + + for name in save_files.split(';'): + if not (name := name.strip()): + continue + + target = resolve_in_rom(prefix, name) + saved = system_saves / target.name + + if target.is_symlink(): + target.unlink() + elif target.exists(): + # the prefix already holds the save, it moves to /userdata/saves once + if saved.exists(): + target.unlink() + else: + mkdir_if_not_exists(saved.parent) + shutil.move(target, saved) + + mkdir_if_not_exists(target.parent) + target.symlink_to(saved) + _logger.debug('saves: %s -> %s', target, saved) + + +# executables that are in a prefix without being the game: the installers that put the +# game there, and what windows itself brings +_NOT_THE_GAME: Final = ( + re.compile(r'/Windows Media Player/'), + re.compile(r'/Windows NT/'), + re.compile(r'/Internet Explorer/'), + re.compile(r'/drive_c/windows/'), + re.compile(r'/unins[a-z0-9]{0,6}\.exe$', re.IGNORECASE), + re.compile(r'/install(..)?\.exe$', re.IGNORECASE), + re.compile(r'/setup\.exe$', re.IGNORECASE), + re.compile(r'/unwise(..)?\.exe$', re.IGNORECASE), +) + +# where the user may add patterns of their own, one regex per line +_AUTORUN_REGEX_FILES: Final = ( + ROMS / 'windows_installers' / 'autorun-regex.txt', + SAVES / 'windows_installers' / 'autorun-regex.txt', +) + + +def _user_autorun_filters() -> list[re.Pattern[str]]: + for regex_file in _AUTORUN_REGEX_FILES: + if not regex_file.is_file(): + continue + + filters: list[re.Pattern[str]] = [] + + for line in regex_file.read_text(encoding='utf-8-sig', errors='replace').splitlines(): + # the file documents itself in #-comments, and blank lines separate them + if not (line := line.strip()) or line.startswith(('#', '*')): + continue + + try: + filters.append(re.compile(line)) + except re.error as e: + _logger.warning('%s: ignoring the pattern %s: %s', regex_file, line, e) + + return filters + + return [] + + +def find_game_executables(directory: Path, file_mask: str = 'drive_c/P*', /) -> list[Path]: + """ + The executables of a prefix or a game directory that could be the game itself, + relative to it, in the order they should be offered. file_mask is a glob narrowing + the search, defaulting to the Program Files directories an installer writes to. + """ + filters = [*_user_autorun_filters(), *_NOT_THE_GAME] + + executables: list[Path] = [] + + for base in sorted(directory.glob(file_mask)) if file_mask not in ('', '.') else [directory]: + if base.is_file(): + candidates: Iterable[Path] = [base] + else: + candidates = base.rglob('*') + + for candidate in candidates: + if candidate.suffix.lower() != '.exe' or not candidate.is_file(): + continue + + relative = candidate.relative_to(directory) + + if not any(pattern.search(f'/{relative}') for pattern in filters): + executables.append(relative) + + return sorted(executables) + + +def write_autorun_cmd(directory: Path, executable: Path | None, /) -> None: + """ + Describe a game in the autorun.cmd of its directory, so that the wine generator + knows what to run. Without an executable, write the commented-out template + batocera-wine wrote, for the user to fill in themselves. + """ + autorun = directory / 'autorun.cmd' + + if autorun.exists(): + backup = autorun.with_name(f'{autorun.name}.bak') + _logger.debug('%s exists, keeping it as %s', autorun, backup) + shutil.move(autorun, backup) + + if executable is None: + autorun.write_text('#DIR=drive_c/Program Files/myprogram\n#CMD=start.exe\n') + return + + autorun.write_text(f'DIR={executable.parent}\nCMD="{executable.name}"\n') + _logger.debug('%s names %s', autorun, executable) + + +def prefix_holders(prefix: Path, /) -> list[int]: + """The processes still running in a prefix, our own excepted.""" + # the whole KEY=VALUE record has to match: a substring search would also find + # the processes of a bottle whose name merely starts with this one's + wanted = f'WINEPREFIX={prefix}'.encode() + own_pid = os.getpid() + holders: list[int] = [] + + for entry in _PROC.iterdir(): + if not entry.name.isdigit() or int(entry.name) == own_pid: + continue + + try: + environ = (entry / 'environ').read_bytes() + except OSError: + # the process is gone, or is one we may not look at + continue + + if wanted in environ.split(b'\0'): + holders.append(int(entry.name)) + + return holders + + +def running_prefixes() -> dict[Path, Path | None]: + """ + The prefixes something is running in, each with the wineserver serving it when one + of its processes is that wineserver. + """ + prefixes: dict[Path, Path | None] = {} + own_pid = os.getpid() + + for entry in _PROC.iterdir(): + if not entry.name.isdigit() or int(entry.name) == own_pid: + continue + + try: + environ = (entry / 'environ').read_bytes() + except OSError: + continue + + for record in environ.split(b'\0'): + if not record.startswith(b'WINEPREFIX='): + continue + + prefix = Path(os.fsdecode(record.removeprefix(b'WINEPREFIX='))) + prefixes.setdefault(prefix, None) + + try: + executable = (entry / 'exe').readlink() + except OSError: + continue + + if executable.name == 'wineserver': + prefixes[prefix] = executable + + break + + return prefixes + + +def stop_all() -> None: + """ + Ask every running game to close, which is what the exit hotkey does. Each prefix is + handed to its own wineserver so that a game running under another runner is stopped + by the wineserver that started it. + """ + prefixes = running_prefixes() + + if not prefixes: + _logger.debug('no wine prefix is running') + return + + for prefix, wineserver in prefixes.items(): + _logger.debug('stopping %s', prefix) + + if wineserver is None: + # nothing that looks like a wineserver, ask the processes themselves + for pid in prefix_holders(prefix): + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + continue + + try: + subprocess.run( + [wineserver, '-k'], + env={**os.environ, 'WINEPREFIX': str(prefix)}, + timeout=_WINESERVER_WAIT_TIMEOUT, + check=False, + ) + except (subprocess.TimeoutExpired, OSError) as e: + _logger.warning('%s -k failed for %s: %s', wineserver, prefix, e) + -type RunnerNames = Literal['wine-tkg', 'wine-proton'] -_DEFAULT_WINE_RUNNER: Final[RunnerNames] = 'wine-tkg' +def _get_file_path_from_iso(media: Path, name: str, /) -> Path | None: + """ + The file an autorun.inf names, on the mounted image. iso9660 without rock ridge + gives its names in upper case and with a version suffix, so nothing can be assumed + of how the name was written. None when the image holds no such file. + """ + current = media + + for part in PureWindowsPath(name).parts: + if part in ('\\', '/'): + continue + + if (candidate := current / part).exists(): + current = candidate + continue + + wanted = part.lower() + + try: + entries = sorted(current.iterdir()) + except OSError: + return None + + for entry in entries: + if entry.name.lower().partition(';')[0] == wanted: + current = entry + break + else: + return None + + return current + + +def _read_autorun_inf(inf: Path, /) -> dict[str, str]: + data = inf.read_bytes() + + if data[:2] in _UTF16_BOMS: + text = data.decode('utf-16', errors='replace') + else: + text = data.decode('utf-8-sig', errors='replace') + + sections: dict[str, dict[str, str]] = {} + entries: dict[str, str] | None = None + + for line in text.splitlines(): + line = line.strip() + + if not line or line.startswith(';'): + continue + + if line.startswith('['): + match = _AUTORUN_SECTION.fullmatch(line) + entries = sections.setdefault((match.group('arch') or '').lower(), {}) if match else None + continue + + key, separator, value = line.partition('=') + + if entries is not None and separator and (key := key.strip().lower()) not in entries: + entries[key] = value.strip() + + return {**sections.get('', {}), **sections.get(_AUTORUN_ARCH, {})} + + +def autorun_inf_command(media: Path, drive: str = 'd:', /) -> list[str] | None: + """ + What the autorun.inf of an installation medium says to run, as a command on the drive + the medium is mounted as, or None when it names nothing that is there. + """ + inf = _get_file_path_from_iso(media, _AUTORUN_INF) + + if inf is None or not inf.is_file(): + return None + + try: + entries = _read_autorun_inf(inf) + except OSError as e: + _logger.warning('unable to read %s: %s', inf, e) + return None + + for key in _AUTORUN_KEYS: + if not (value := entries.get(key)): + continue + + tokens = [token.strip('"') for token in shlex.split(value, posix=False)] + + if not tokens: + continue + + if (target := _get_file_path_from_iso(media, tokens[0])) is None or not target.is_file(): + _logger.warning('%s names %s, which is not on the medium', inf, tokens[0]) + continue + + return [str(PureWindowsPath(f'{drive}\\', *target.relative_to(media).parts)), *tokens[1:]] + + return None + + +@contextmanager +def mount_iso(image: Path, /) -> Generator[Path]: + """An installer image, mounted for as long as the installer needs it.""" + mount_point = Path('/var/run/wine') / f'{image.name}.cdrom' + mount_point.mkdir(parents=True, exist_ok=True) + + for filesystem in ('iso9660', 'udf'): + if subprocess.call(['mount', '-t', filesystem, image, mount_point]) == 0: + break + else: + mount_point.rmdir() + raise BatoceraException(f'Unable to mount the image {image}') + + try: + yield mount_point + finally: + subprocess.call(['umount', '-l', mount_point]) + try: + mount_point.rmdir() + except OSError: + _logger.debug('%s is not empty, leaving it', mount_point) @dataclass class Runner: - name: InitVar[RunnerNames] - bottle_name: InitVar[str] + name: InitVar[str] + bottle_name: InitVar[str | None] = None + # a prefix that already exists, for roms that carry their own + prefix: InitVar[Path | None] = None + # win32 to build a 32bit prefix, None to let wine decide + arch: str | None = None + runner_name: str = field(init=False) bottle_dir: Path = field(init=False) wine: Path = field(init=False) wine64: Path = field(init=False) + wineserver: Path = field(init=False) + msiexec: Path = field(init=False) - __lib: Path = field(init=False) + __lib32: Path = field(init=False) + __lib64: Path = field(init=False) __env_path: str = field(init=False) + __ld_library_path: str = field(init=False) - def __post_init__(self, name: RunnerNames, bottle_name: str) -> None: - wine_path = WINE_BASE / name + def __post_init__(self, name: str, bottle_name: str | None, prefix: Path | None) -> None: + runner_name = get_runner_name(name) + wine_path = get_runner_path(runner_name) wine_lib = wine_path / 'lib' / 'wine' wine_server_bin = wine_path / 'bin' - if name == 'wine-proton': - wine_bin = wine_server_bin - wine64_bin = wine_server_bin - wine = wine_bin / 'wine' - wine64 = wine64_bin / 'wine64' + # split-arch builds keep a loader per architecture under lib/wine, while + # wow64-only builds (wine-proton, Wine 11.10+) only ship bin/wine + wine_bin = wine_lib / 'i386-unix' + wine64_bin = wine_lib / 'x86_64-unix' - # Fallback for Wine 11.10+ where wine64 does not exist - if not wine64.exists(): - wine64 = wine + # only a split-arch build has a real WINEARCH=win32 mode: a wow64-only one + # doesn't have one and doesn't need it, it runs 32bit exes in its own prefix + supports_win32 = (wine_bin / 'wine').exists() - env_path = f'{wine_server_bin}:/bin:/usr/bin' - else: - wine_bin = wine_lib / 'i386-unix' - wine64_bin = wine_lib / 'x86_64-unix' + if runner_name != 'wine-proton' and supports_win32: wine = wine_bin / 'wine' wine64 = wine64_bin / 'wine64' @@ -63,37 +767,274 @@ def __post_init__(self, name: RunnerNames, bottle_name: str) -> None: env_path = f'{wine_bin}:{wine_server_bin}:/bin:/usr/bin' else: env_path = f'{wine_bin}:{wine64_bin}:{wine_server_bin}:/bin:/usr/bin' + else: + wine = wine_server_bin / 'wine' + wine64 = wine_server_bin / 'wine64' + + # Fallback for Wine 11.10+ where wine64 does not exist + if not wine64.exists(): + wine64 = wine + + env_path = f'{wine_server_bin}:/bin:/usr/bin' + + if prefix is not None: + bottle_dir = prefix + elif bottle_name is not None: + bottle_dir = WINE_BOTTLES / bottle_name + else: + raise BatoceraException('A wine runner needs either a bottle name or a prefix') + + # a build may split its windows dlls per architecture, or keep both under lib + lib64 = wine_path / 'lib64' / 'wine' + lib32 = wine_path / 'lib32' / 'wine' + self.runner_name = runner_name self.wine = wine self.wine64 = wine64 - self.bottle_dir = _WINE_BOTTLES / bottle_name - self.__lib = wine_lib + self.wineserver = wine_server_bin / 'wineserver' + self.msiexec = wine_server_bin / 'msiexec' + self.bottle_dir = bottle_dir + self.__lib64 = lib64 = lib64 if lib64.is_dir() else wine_lib + self.__lib32 = lib32 = lib32 if lib32.is_dir() else wine_lib self.__env_path = env_path + self.__ld_library_path = f'/lib32:{lib32}/i386-unix:/lib:/usr/lib:{lib64}/x86_64-unix' - def __run_wine_process( + # an existing prefix keeps the architecture it was built with, whatever was asked. + # only a 32bit one has to be named: wine reads the rest from the prefix itself, + # and batocera-wine never exported anything but WINEARCH=win32 either + if (existing_prefix_arch := current_prefix_arch(bottle_dir)) is not None: + _logger.info('found arch %s recorded in the prefix %s', existing_prefix_arch, bottle_dir) + + if self.arch == 'win32' and existing_prefix_arch != 'win32': + _logger.warning( + 'the prefix is %s in the registry, it cannot be turned into a 32bit one: ' + 'delete it to have it rebuilt, or turn enable_win32 off', + existing_prefix_arch, + ) + + self.arch = existing_prefix_arch if existing_prefix_arch == 'win32' else None + elif self.arch is None: + _logger.info('building the prefix as win64') + + if self.arch == 'win32' and not supports_win32: + if existing_prefix_arch == 'win32': + raise BatoceraException( + f'{bottle_dir} is a win32 prefix but {runner_name} has no win32 mode anymore: ' + 'delete and recreate it, or go back to a runner that has one' + ) + + # a 32bit prefix was asked for a runner that has no such thing, ignore it: + # 32bit games still run, through wow64 + _logger.info('%s has no win32 mode, building a win64 prefix instead', runner_name) + self.arch = None + + def __wine_environment(self, /) -> dict[str, str | Path]: + """ + What wine needs of its runner, whoever starts it: the game, the installer of a + redist, winetricks, the bootstrap of a prefix. batocera-wine exported these once + in init_wine, and so had them in everything it went on to run. + """ + environment: dict[str, str | Path] = { + 'WINEPREFIX': self.bottle_dir, + 'LD_LIBRARY_PATH': self.__ld_library_path, + 'WINEDLLPATH': f'{self.__lib32}/i386-windows:{self.__lib64}/x86_64-windows', + 'LIBGL_DRIVERS_PATH': '/lib32/dri:/usr/lib/dri', + 'GST_PLUGIN_SYSTEM_PATH_1_0': '/usr/lib/gstreamer-1.0:/lib32/gstreamer-1.0', + # hum pw 0.2 and 0.3 are hardcoded, not nice + 'SPA_PLUGIN_DIR': '/usr/lib/spa-0.2:/lib32/spa-0.2', + 'PIPEWIRE_MODULE_DIR': '/usr/lib/pipewire-0.3:/lib32/pipewire-0.3', + } + + if self.arch: + environment['WINEARCH'] = self.arch + + return environment + + def run_in_prefix( self, cmd: Sequence[str | Path], /, *, - environment: Mapping[str, str | Path] | None = None + environment: Mapping[str, str | Path] | None = None, + wait: bool = True, + capture_output: bool = True, ) -> None: - env = { - 'LD_LIBRARY_PATH': f'/lib32:{self.__lib}', - 'WINEPREFIX': self.bottle_dir, - } + """ + Run something in the prefix and, unless told otherwise, wait for the wineserver + to be done with it before returning. - if environment: - env.update(environment) + What it writes goes to the log, unless capture_output is off: something that + talks to whoever started it, such as winetricks downloading, keeps the terminal. + """ + env: dict[str, str | Path] = {**os.environ, **self.__wine_environment()} - env.update(os.environ) env['PATH'] = self.__env_path + if environment: + env.update(environment) + _logger.debug('command: %s', cmd) - proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + pipe = subprocess.PIPE if capture_output else None + + proc = subprocess.Popen(cmd, env=env, stdout=pipe, stderr=pipe) out, err = proc.communicate() - _logger.debug(out.decode()) - _logger.error(err.decode()) + if capture_output: + for stream in (out, err): + if text := stream.decode(errors='backslashreplace').strip(): + _logger.debug('%s', text) + + # only what failed is worth an error, wine writes to stderr all the time + if proc.returncode: + _logger.error('%s exited with %s', Path(cmd[0]).name, proc.returncode) + + if wait: + self.wait() + + def wait(self, /) -> None: + """Wait for the wineserver to be done with whatever was started in the prefix.""" + try: + subprocess.run( + [self.wineserver, '-w'], + env={**os.environ, 'WINEPREFIX': str(self.bottle_dir), 'PATH': self.__env_path}, + timeout=_WINESERVER_WAIT_TIMEOUT, + check=False, + ) + except (subprocess.TimeoutExpired, OSError) as e: + _logger.warning('wineserver -w failed for %s: %s', self.bottle_dir, e) + + def create_bottle(self, /) -> None: + # nothing to do, the prefix is already bootstrapped + if (self.bottle_dir / 'system.reg').exists(): + return + + self.bottle_dir.mkdir(parents=True, exist_ok=True) + + # show a please wait screen, bootstrapping a prefix takes a while + splash = None + if _BSOD.exists(): + splash = subprocess.Popen([_BSOD]) + + try: + # winegstreamer is disabled, it makes the bootstrap hang when wine debug is on + self.run_in_prefix([self.wine, 'hostname'], environment={'WINEDLLOVERRIDES': 'winegstreamer='}) + finally: + if splash is not None: + splash.terminate() + + if not (self.bottle_dir / 'system.reg').exists(): + shutil.rmtree(self.bottle_dir, ignore_errors=True) + raise BatoceraException(f'Failed initialising the wine prefix {self.bottle_dir}') + + def game_command(self, game_exe: Path, /) -> list[str | Path]: + return [self.wine, game_exe] + + @contextmanager + def running(self, /) -> Generator[None]: + # a rom on a squashfs is unmounted the moment the game is over, so leaving this + # block may only return once nothing is left holding it, which is what + # batocera-wine's stopWineServer did: wine hands the game over to the wineserver + # and exits long before the game does, and a game that was killed rather than + # closed leaves wine processes behind that the wineserver won't reap + try: + yield + finally: + self.__stop_wineserver() + self.__kill_prefix_holders() + + def __stop_wineserver(self, /) -> None: + env = {**os.environ, 'WINEPREFIX': str(self.bottle_dir), 'PATH': self.__env_path} + + try: + # -w waits for the prefix to be done with, and returns once it is + subprocess.run([self.wineserver, '-w'], env=env, timeout=_WINESERVER_WAIT_TIMEOUT, check=False) + except subprocess.TimeoutExpired: + # it isn't going to finish on its own, tell it to kill its clients + _logger.debug('wineserver -w timed out for %s, killing the prefix', self.bottle_dir) + try: + subprocess.run([self.wineserver, '-k'], env=env, timeout=_WINESERVER_WAIT_TIMEOUT, check=False) + except (subprocess.TimeoutExpired, OSError) as e: + _logger.warning('wineserver -k failed for %s: %s', self.bottle_dir, e) + except OSError as e: + _logger.warning('wineserver -w failed for %s: %s', self.bottle_dir, e) + + def __kill_prefix_holders(self, /) -> None: + for _ in range(_PREFIX_KILL_ATTEMPTS): + if not (holders := prefix_holders(self.bottle_dir)): + return + + _logger.debug('killing %s still holding %s', holders, self.bottle_dir) + + for pid in holders: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + # already gone, or not ours to kill + pass + + time.sleep(_PREFIX_KILL_INTERVAL) + + if holders := prefix_holders(self.bottle_dir): + _logger.warning('%s is still held by %s', self.bottle_dir, holders) + + def __link_dlls(self, source_dir: Path, windows_dir: str, dlls: Iterable[Path], /) -> set[str]: + target_dir = self.bottle_dir / 'drive_c' / 'windows' / windows_dir + target_dir.mkdir(parents=True, exist_ok=True) + + linked: set[str] = set() + + for dll in sorted(dlls): + link = target_dir / dll.name + + # wine puts its own builtin dll there when it bootstraps the prefix + if link.is_symlink() or link.exists(): + link.unlink() + + link.symlink_to(dll) + linked.add(dll.stem) + + if linked: + _logger.debug('linked %s from %s into %s', ', '.join(sorted(linked)), source_dir, target_dir) + + return linked + + def install_dxvk(self, /) -> set[str]: + # dxvk isn't built for every architecture, and the dlls it ships change between + # releases, so link whatever is actually there rather than a hardcoded list: + # symlinking a missing dll would leave a dangling link that wine cannot load + overridden: set[str] = set() + + dxvk = _USER_DXVK if _USER_DXVK.is_dir() else _DXVK + + for arch, windows_dir in _DXVK_ARCHS: + source_dir = dxvk / arch + + if not source_dir.is_dir(): + continue + + overridden |= self.__link_dlls(source_dir, windows_dir, source_dir.glob('*.dll')) + + return overridden + + def install_builtin_d3d(self, /) -> set[str]: + """ + Point the direct3d dlls of the prefix back at the ones wine ships, for a game + that is to run on wined3d rather than on dxvk. A prefix that ran with dxvk still + holds links into the dxvk build, so they have to be replaced, not just left out. + """ + installed: set[str] = set() + + for arch, windows_dir in _BUILTIN_ARCHS: + source_dir = (self.__lib64 if windows_dir == 'system32' else self.__lib32) / arch + + if not source_dir.is_dir(): + continue + + dlls = [dll for name in _D3D_DLLS if (dll := source_dir / f'{name}.dll').is_file()] + + installed |= self.__link_dlls(source_dir, windows_dir, dlls) + + return installed def install_wine_trick( self, @@ -106,23 +1047,196 @@ def install_wine_trick( if done_file.exists(): return - self.__run_wine_process([_WINETRICKS, '-q', name], environment=environment) + self.run_winetricks(['-q', name], environment=environment) done_file.write_text('done') + def run_winetricks( + self, + arguments: Sequence[str], + /, *, + environment: Mapping[str, str | Path] | None = None, + capture_output: bool = True, + ) -> None: + if not _WINETRICKS.exists(): + raise BatoceraException(f'{_WINETRICKS} is missing, winetricks is not installed') + + environment = dict(environment or {}) + + # a trick installs through the installer of the redist, which wants a display: + # called over ssh there is none, so it is given the session batocera runs + if not any( + environment.get(name) or os.environ.get(name) for name in ('DISPLAY', 'WAYLAND_DISPLAY') + ): + display = local_display() + _logger.debug('no display in the environment, using %s', display) + environment.update(display) + + self.run_in_prefix( + [_WINETRICKS, *arguments], environment=environment, capture_output=capture_output + ) + def regedit(self, file: Path, /) -> None: - self.__run_wine_process([self.wine, 'regedit', file]) + """Import a registry file, into both architectures of the prefix.""" + # //?/unix/... is how wine is given a linux path where it expects a windows one + for wine in {self.wine, self.wine64}: + self.run_in_prefix([wine, 'regedit', f'//?/unix{file}']) + + def set_registry_value(self, key: str, name: str, value_type: str, value: str, /) -> None: + self.run_in_prefix([self.wine, 'reg', 'add', key, '/v', name, '/t', value_type, '/d', value, '/f']) + + def sandbox_prefix(self, /) -> None: + """ + Replace the links wine puts in the user directories of a prefix by directories + of their own, so that a game saving to My Documents writes inside the prefix + rather than all over /userdata/system. + """ + users = self.bottle_dir / 'drive_c' / 'users' + + if not users.is_dir(): + return + + # don't create every directory: linking Music and My Music both is what wine + # avoids by shipping the one its version uses + for user in users.iterdir(): + for name in ( + 'Downloads', 'Documents', 'My Documents', 'Music', 'My Music', + 'Pictures', 'My Pictures', 'Videos', 'My Videos', 'Templates', + ): + directory = user / name + + if directory.is_symlink(): + directory.unlink() + directory.mkdir(parents=True, exist_ok=True) + _logger.debug('sandboxed %s', directory) + + def __list_files_from_user_dir(self, name: str, suffixes: Iterable[str], /) -> list[Path]: + """ + The files the user dropped in /userdata/system/wine/ for the prefix to + take in. They are moved to installed. once they have been, so that they + are only imported once. + """ + source_dir = USER_WINE_DIR / name + + if not source_dir.is_dir(): + return [] + + wanted = {suffix.lower() for suffix in suffixes} + files = sorted( + file for file in source_dir.iterdir() + if file.is_file() and file.suffix.lower() in wanted + ) + + if not files: + # nothing of ours in there, and an empty directory is just noise + try: + source_dir.rmdir() + except OSError: + _logger.warning('%s holds files that are not %s, leaving them', source_dir, ' or '.join(wanted)) + + return files + + def __imported(self, file: Path, name: str, /) -> None: + installed_dir = USER_WINE_DIR / f'installed.{name}' + installed_dir.mkdir(parents=True, exist_ok=True) + + target = installed_dir / file.name + + # keep whatever is already there, the file may be a newer build of the same thing + if target.exists(): + target = installed_dir / f'{file.name}.{datetime.now():%y%m%d-%H%M%S}' + + shutil.move(file, target) + + with _IMPORT_LOG.open('a') as log: + log.write(f'{datetime.now():%D %T} - Installed: {file} --> {self.bottle_dir}\n') + + _logger.debug('imported %s into %s', file, self.bottle_dir) + + def install_redists(self, /) -> None: + """Run the redistributables the user dropped in /userdata/system/wine/exe.""" + for installer in self.__list_files_from_user_dir('exe', ['.exe']): + match installer.name.lower(): + case 'dxsetup.exe': + arguments = ['/silent'] + case name if name.startswith('vcredist_') and ('2005' in name or '2008' in name): + arguments = ['/q'] + case name if name.startswith('vcredist_'): + arguments = ['/quiet', '/qn', '/norestart'] + case 'oalinst.exe': + arguments = ['/s'] + case _: + arguments = [] + + _logger.info('installing %s', installer) + self.run_in_prefix([self.wine, installer, *arguments]) + self.__imported(installer, 'exe') + + def install_msis(self, /) -> None: + """Install the msi packages the user dropped in /userdata/system/wine/msi.""" + for package in self.__list_files_from_user_dir('msi', ['.msi']): + _logger.info('installing %s', package) + self.run_in_prefix([self.msiexec, '-i', package, '/quiet', '/qn', '/norestart']) + self.__imported(package, 'msi') + + def install_regs(self, /) -> None: + """Import the registry files the user dropped in /userdata/system/wine/regs.""" + for registry in self.__list_files_from_user_dir('regs', ['.reg']): + _logger.info('importing %s', registry) + self.regedit(registry) + self.__imported(registry, 'regs') + + def install_fonts(self, /) -> None: + """Add the fonts the user dropped in /userdata/system/wine/fonts to the prefix.""" + fonts_dir = self.bottle_dir / 'drive_c' / 'windows' / 'Fonts' + + for font in self.__list_files_from_user_dir('fonts', ['.ttf', '.ttc']): + fonts_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(font, fonts_dir / font.name) + self.__imported(font, 'fonts') + + def install_rawinput(self, /) -> None: + """Import /var/run/rawinput.reg into the prefix, if something left one there.""" + if _RAWINPUT_REG.is_file(): + self.regedit(_RAWINPUT_REG) + _RAWINPUT_REG.unlink() + + def set_hidraw(self, enabled: bool, /) -> None: + """ + Let wine read gamepads through hidraw, which modern games need for the extra + features of a controller, and which breaks older x-input only ones. + """ + wanted = f'"DisableHidraw"=dword:{0 if enabled else 1:08d}' + + try: + registry = (self.bottle_dir / 'system.reg').read_text(encoding='utf-8', errors='replace') + except OSError: + registry = '' + + # writing it costs a wine start, so only do it when it isn't already set + if wanted in registry: + return + + self.set_registry_value( + r'HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\winebus', + 'DisableHidraw', 'REG_DWORD', '0' if enabled else '1', + ) def get_environment(self, /) -> dict[str, str | Path]: return { - 'WINEPREFIX': self.bottle_dir, - 'LD_LIBRARY_PATH': f'/lib32:{self.__lib}', - 'LIBGL_DRIVERS_PATH': '/lib32/dri', - # hum pw 0.2 and 0.3 are hardcoded, not nice - 'SPA_PLUGIN_DIR': '/usr/lib/spa-0.2:/lib32/spa-0.2', - 'PIPEWIRE_MODULE_DIR': '/usr/lib/pipewire-0.3:/lib32/pipewire-0.3', + **self.__wine_environment(), + # so that the game finds the wineserver and the tools of its own runner + 'PATH': f'{self.wineserver.parent}:{os.environ.get("PATH", "/bin:/usr/bin")}', } @classmethod - def default(cls, bottle_dir: str, /) -> Self: - return cls(_DEFAULT_WINE_RUNNER, bottle_dir) + def default(cls, bottle_name: str, /) -> Self: + return cls(_DEFAULT_WINE_RUNNER, bottle_name) + + @classmethod + def from_prefix(cls, name: str | None, prefix: Path, /, *, arch: str | None = None) -> Self: + """ + A runner for a prefix given as a path rather than named as a bottle: a rom that + is a prefix itself, or a prefix that is about to be built somewhere of its own. + """ + return cls(name or _DEFAULT_WINE_RUNNER, prefix=prefix, arch=arch) diff --git a/package/batocera/wine/batocera-wine/Config.in b/package/batocera/wine/batocera-wine/Config.in index 11da70b5b21..5c64d92cacf 100644 --- a/package/batocera/wine/batocera-wine/Config.in +++ b/package/batocera/wine/batocera-wine/Config.in @@ -1,6 +1,7 @@ config BR2_PACKAGE_BATOCERA_WINE bool "batocera-wine" select BR2_PACKAGE_BATOCERA_WINE_REQUIREMENTS + select BR2_PACKAGE_BATOCERA_CONFIGGEN select BR2_PACKAGE_WINETRICKS select BR2_PACKAGE_CABEXTRACT select BR2_PACKAGE_DXVK diff --git a/package/batocera/wine/batocera-wine/batocera-wine b/package/batocera/wine/batocera-wine/batocera-wine index 859a0929f3d..049841edcb9 100755 --- a/package/batocera/wine/batocera-wine/batocera-wine +++ b/package/batocera/wine/batocera-wine/batocera-wine @@ -1,1264 +1,339 @@ -#!/bin/bash -SYSTEM=${1,,} # windows, mugen -ACTION=${2,,} # play, stop... see usage -GAMENAME="$3" # fullpath to game: e.g. /userdata/roms/windows/Age of Empires.wine -TRICK=${4,,} # WINE-tricks, see https://github.com/Winetricks/winetricks -ROMGAMENAME=$(basename "${GAMENAME}") # gamedir or gameexecutable: e.g. "Age of Empires.wine" or "AoE_inst.exe" -ROMBASEDIR=$(dirname "${GAMENAME}") # gamedir or rootdir: eg "/userdata/roms/windows" if AoE is a wine.dir -GAMEEXT="${GAMENAME##*.}" # gameextension: pc, wine, exe, msi -INST_ROMGAMENAME= # installed game with stripped extension and unique filename based on time+date -# log scripts (aka winserver) running time with bash builtin command -SECONDS=0 - -## Folders -WINE_BOTTLE_DIR="/userdata/system/wine-bottles/${SYSTEM}" # Basestorage for our bottles, more variables in init_wine() -G_ROMS_DIR="/userdata/roms/${SYSTEM}" # Gamesdir for our games, more variables in init_wine() - -## WINE-VARS, these need to be prepared in init_wine() -## in general Wine detection routines, for specific game if entered in batocera.conf -G_RESCUR= -WINE_RUNNER= -WINE_VERSION= -## Wine executables, to be populated in init_wine() with find_wine_dir() -## variables heavily depend on DIR-entry, found in init_wine() -DIR= -USER_DIR= -WINE= -WINE64= -WINESERVER= -MSIEXEC= -WINETRICKS= -# Save function carried out in saveFilesToUserdata() -WINE_SAVEDIR= -WINE_SAVEFILES= -SYSTEM_SAVEDIR= -# createAutorunCmd() tries to creates automatically autorun.cmd if you use windows_installs with msi/exe -# a basic exe-filter is here in the script, user can set advanced one, see i and ii in function -AUTORUN_FILEMASK="$4" -AUTORUN_FOUNDEXE= -#AUTORUN_FILTER= -# Use requestFileSystem() to check dirs and files for symlinks and filesystem, it's a small dev-tool for monitoring the entire script -# Usage requestFileSystem "FILE" "DIR" "FILE" - -# Global variables for Wayland/X.org handling, SYSTEM_DISPLAY_MODE is in init_wine() -SYSTEM_DISPLAY_MODE="" -CMD_PREFIX="" - -stopWineServer() { - [[ -z "${WINESERVER}" || -z "${WINEPOINT}" ]] && exit 0 - - #try to cleanly exit wineserver - WINEPREFIX=${WINEPOINT} "${WINESERVER}" -k & - #wait 10s for clean exit - echo "${FUNCNAME[0]}: waiting wineserver shutdown" >&2 - if waitWineServer 10; then - echo "${FUNCNAME[0]}: wineserver has cleanly exited" >&2 - return 0 - fi - - #kill all process with wineprefix as envvar if wineserver is still not stopped - declare -a PIDS - - # find all process with wineprefix environement - for PID in $(ps -e -o pid=); do - if grep -z "WINEPREFIX=${WINEPOINT}" "/proc/$PID/environ" 2>/dev/null; then - PIDS+=("$PID") - fi - done - - # kill all pids - if [ ${#PIDS[@]} -gt 0 ]; then - echo "${FUNCNAME[0]}: Killing stuck wine processes: ${PIDS[*]}" - kill -9 "${PIDS[@]}" - fi - return 1 -} +#!/usr/bin/env python3 +""" +batocera-wine - the wine prefix tools of batocera. -# Arguments: key, system, game -get_setting() { - /usr/bin/batocera-settings-get "$2[\"$3\"].$1" "$2.$1" "global.$1" -} +Playing a game and installing one are the wine generator's job, this is what is left: +the tools that work on a prefix from a shell, from the wine toolbox of the desktop, or +from the exit hotkey. +""" -find_wine_dir() { - local WINE_VERSION="$1" - # check if we're using a custom wine runner - if [[ ! "$WINE_VERSION" =~ ^wine-(tkg|proton)$ ]]; then - # now check if the folder exists - if [[ -e "/userdata/system/wine/custom/${WINE_VERSION}" ]]; then - echo "/userdata/system/wine/custom" - else - WINE_VERSION="wine-tkg" - if [[ -e "/usr/wine/${WINE_VERSION}" ]]; then - echo "/usr/wine" - else - return 1 - fi - fi - elif [[ -e "/usr/wine/${WINE_VERSION}" ]]; then - echo "/usr/wine" - else - return 1 - fi -} +from __future__ import annotations -update_wine_version() { - local WINE_VERSION="$1" - if [[ ! "$WINE_VERSION" =~ ^wine-(tkg|proton)$ ]]; then - if [[ -e "/userdata/system/wine/custom/${WINE_VERSION}" ]]; then - echo "$WINE_VERSION" - else - WINE_VERSION="wine-tkg" - echo "$WINE_VERSION" - fi - else - echo "$WINE_VERSION" - fi -} +import argparse +import logging +import shutil +import subprocess +import sys +import tarfile +from pathlib import Path +from typing import TYPE_CHECKING -setPrefixArch(){ - # Parameter 1 = current Winepoint - # Parameter 2 = current WineRunner - # Parameter 3 = action - - ENABLE_WIN32="$(get_setting enable_win32 "${SYSTEM}" "${ROMGAMENAME}")" - - # Does the selected runner support real WINEARCH=win32 prefixes? - # (classic split-arch build has i386-unix; wow64-only builds don't - # and don't need it -- they run 32-bit exes inside their normal prefix.) - local SUPPORTS_WIN32=0 - [[ -e "${DIR}/${WINE_VERSION}/lib/wine/i386-unix/wine" ]] && SUPPORTS_WIN32=1 - - local val - if [[ -e "${1}/userdef.reg" ]]; then - if val=$(grep -Po '^#arch=\K.*' "${1}/userdef.reg"); then - echo "${FUNCNAME[0]}: Found arch '${val}' in Prefix-registry-file" - if [[ $val == win32 && $3 == tricks ]]; then - test -d "${1}/drive_c/windows/syswow64" && { rm -r -- "$_"; echo "${FUNCNAME[0]}: removed $_"; } - elif [[ $val != win32 && ${ENABLE_WIN32} -eq 1 ]]; then - echo "${FUNCNAME[0]}: Did found '$val' in registry - Prefix can't be set to 32bit!" - elif [[ $val == win32 ]]; then - if [[ ${SUPPORTS_WIN32} -eq 0 ]]; then - echo "${FUNCNAME[0]}: ERROR: Prefix is a real win32 prefix but runner '${WINE_VERSION}' no longer supports WINEARCH=win32. Delete and recreate this prefix, or switch back to a runner that supports it." >&2 - return 1 - fi - export WINEARCH=win32 - fi - fi - else - # Missing key, perform further checks and perform a fresh install with createWineDirectory() - if [[ ${ENABLE_WIN32} == 1 && ! -d "${1}" ]]; then - if [[ ${SUPPORTS_WIN32} -eq 1 ]]; then - export WINEARCH=win32; val="ENABLE WIN32 in ES setted 'win32'" - else - val="win64 (ENABLE WIN32 ignored - runner '${WINE_VERSION}' has no true win32 mode; 32-bit apps still run via wow64)" - fi - elif [[ "${2,,}" =~ ^win32[-_.].*$ && ! -d "${1}" ]]; then - if [[ ${SUPPORTS_WIN32} -eq 1 ]]; then - export WINEARCH=win32; val="RUNNER carries 'win32' value" - else - val="win64 (runner name carries 'win32' but current build has no true win32 mode; ignored)" - fi - else - val=win64 - fi - fi - echo "${FUNCNAME[0]}: Environment Prefix setted -> $val" -} +from configgen.batoceraPaths import BATOCERA_CONF, ROMS +from configgen.config import Config +from configgen.exceptions import BatoceraException +from configgen.settings.unixSettings import UnixSettings +from configgen.utils import wine -waitWineServer() { - local ret=0 pid=$(pgrep -o -f "${WINESERVER}") - [[ -z "$pid" ]] && { echo "${FUNCNAME[0]}: Called by ${FUNCNAME[1]} no process '${WINESERVER}' found - finished with hotkey?" >&2; return 0; } - #from: https://unix.stackexchange.com/a/427133 - #timeout will report not 0 if setted value is reached - echo "${FUNCNAME[0]}: Called by ${FUNCNAME[1]} wait WineServer: [$pid] ${WINESERVER}" - timeout "$1" tail -q --pid=$pid -f /dev/null - ret=$? - echo "${FUNCNAME[0]}: Finished waiting for WineServer [$pid] with errorcode($ret)" - return $ret -} +if TYPE_CHECKING: + from collections.abc import Sequence -wine_options() { - WINEPOINT=$1 - NTSYNC="$(get_setting ntsync "${SYSTEM}" "${ROMGAMENAME}")" - PBA="$(get_setting pba "${SYSTEM}" "${ROMGAMENAME}")" - FSR="$(get_setting fsr "${SYSTEM}" "${ROMGAMENAME}")" - FPS_LIMIT="$(get_setting fps_limit "${SYSTEM}" "${ROMGAMENAME}")" - ALLOW_XIM="$(get_setting allow_xim "${SYSTEM}" "${ROMGAMENAME}")" - NO_WRITE_WATCH="$(get_setting no_write_watch "${SYSTEM}" "${ROMGAMENAME}")" - FORCE_LARGE_ADRESS="$(get_setting force_large_adress "${SYSTEM}" "${ROMGAMENAME}")" - HEAP_DELAY_FREE="$(get_setting heap_delay_free "${SYSTEM}" "${ROMGAMENAME}")" - HIDE_NVIDIA_GPU="$(get_setting hide_nvidia_gpu "${SYSTEM}" "${ROMGAMENAME}")" - ENABLE_NVAPI="$(get_setting enable_nvapi "${SYSTEM}" "${ROMGAMENAME}")" - ENABLE_VKREFLEX="$(get_setting enable_vkreflex "${SYSTEM}" "${ROMGAMENAME}")" - ENABLE_HIDRAW="$(get_setting enable_hidraw "${SYSTEM}" "${ROMGAMENAME}")" - DXVK_RESET_CACHE="$(get_setting dxvk_reset_cache "${SYSTEM}" "${ROMGAMENAME}")" - WINE_NTFS="$(get_setting wine_ntfs "${SYSTEM}" "${ROMGAMENAME}")" - WINE_DEBUG="$(get_setting wine_debug "${SYSTEM}" "${ROMGAMENAME}")" - KEYBOARD="$(/usr/bin/batocera-settings-get system.kblayout)" - VIRTUAL_DESKTOP="$(get_setting virtual_desktop "${SYSTEM}" "${ROMGAMENAME}")" - VIRTUAL_DESKTOP_SIZE="$(get_setting videomode "${SYSTEM}" "${ROMGAMENAME}" || batocera-resolution currentResolution)" - WAYLAND_DRIVER="$(get_setting wayland_driver "${SYSTEM}" "${ROMGAMENAME}")" - - # Reflex requires NVAPI to function; force it on if Reflex is enabled - if [[ "${ENABLE_VKREFLEX}" = 1 ]]; then - ENABLE_NVAPI=1 - fi - - VDESKTOP="" - if [[ "${VIRTUAL_DESKTOP}" = 1 ]]; then - VDESKTOP="explorer /desktop=Wine,${VIRTUAL_DESKTOP_SIZE}" - fi - - # Handle Wayland driver option only if the system is running Wayland - if [[ "${SYSTEM_DISPLAY_MODE}" == "wayland" ]]; then - if [[ "${WAYLAND_DRIVER}" != "native" ]]; then - echo "*** System is Wayland, using XWayland by default or as requested. ***" - setxkbmap "${KEYBOARD}" - else - CMD_PREFIX="DISPLAY=" - echo "*** System is Wayland and 'native' driver requested. Forcing Wine native Wayland mode. ***" - fi - else - # System is X.org, run as normal - echo "*** System is X.org. Running in standard X11 mode. ***" - setxkbmap "${KEYBOARD}" - fi - - # ESYNC and FSYNC are deprecated in favor of NTSYNC with modern Wine and kernel. - # NTSYNC is often enabled by default when supported. - # The option is provided to explicitly disable it if needed. - if [ "$NTSYNC" = "0" ]; then - # Disable ntsync module if loaded - if lsmod | grep -q ^ntsync; then - echo "Removing ntsync module..." - rmmod ntsync - else - echo "Ntsync module already not loaded" - fi - export WINEDISABLEFASTSYNC=1 - echo "Ntsync disabled for Wine" - else - # Enable ntsync module if available and accessible - if lsmod | grep -q ^ntsync; then - echo "Ntsync module already loaded" - else - echo "Loading ntsync module..." - modprobe ntsync - fi - - if [ -e /dev/ntsync ] && [ -r /dev/ntsync ]; then - export WINEDISABLEFASTSYNC=0 - echo "Ntsync enabled for Wine" - else - echo "Warning: /dev/ntsync not accessible after module load, disabling ntsync in Wine" - export WINEDISABLEFASTSYNC=1 - fi - fi - - export PBA_ENABLE=0 - [[ "${PBA}" = 1 ]] && PBA_ENABLE=1 - - export DXVK_FRAME_RATE=0 - [[ "${FPS_LIMIT}" = 1 ]] && DXVK_FRAME_RATE=60 - - export WINEDEBUG="-all" - [[ "${WINE_DEBUG}" = 1 ]] && WINEDEBUG="err+all,fixme+all" - - export DXVK_LOG_LEVEL=none - [[ "${WINE_DEBUG}" = 1 ]] && unset DXVK_LOG_LEVEL - - export VKD3D_DEBUG=none - [[ "${WINE_DEBUG}" = 1 ]] && unset VKD3D_DEBUG - - export VKD3D_SHADER_CACHE_PATH="/userdata/system/cache" - - export WINE_ENABLE_HIDRAW=0 - [[ "${ENABLE_HIDRAW}" = 1 ]] && WINE_ENABLE_HIDRAW=1 - - export WINE_FULLSCREEN_FSR=0 - [[ "${FSR}" = 1 ]] && unset WINE_FULLSCREEN_FSR - - # Wine-mono override for FNA games - export WINE_MONO_OVERRIDES="Microsoft.Xna.Framework.*,Gac=n" - - # Disable XIM support until libx11 >= 1.7 is widespread - export WINE_ALLOW_XIM=0 - [[ "${ALLOW_XIM}" = 1 ]] && WINE_ALLOW_XIM=1 - - # Advanced options from proton - export WINE_DISABLE_WRITE_WATCH=0 - [[ "${NO_WRITE_WATCH}" = 1 ]] && WINE_DISABLE_WRITE_WATCH=1 - - export WINE_LARGE_ADDRESS_AWARE=0 - [[ "${FORCE_LARGE_ADRESS}" = 1 ]] && WINE_LARGE_ADDRESS_AWARE=1 - - export WINE_HEAP_DELAY_FREE=0 - [[ "${HEAP_DELAY_FREE}" = 1 ]] && WINE_HEAP_DELAY_FREE=1 - - export WINE_HIDE_NVIDIA_GPU=0 - [[ "${HIDE_NVIDIA_GPU}" = 1 ]] && WINE_HIDE_NVIDIA_GPU=1 - - export NVAPI=0 - [[ "${ENABLE_NVAPI}" = 1 ]] && NVAPI=1 - - # Vulkan Reflex implicit layer controls - if [[ "${ENABLE_VKREFLEX}" = 1 ]]; then - export DXVK_NVAPI_VKREFLEX=1 - unset DISABLE_DXVK_NVAPI_VKREFLEX - else - export DISABLE_DXVK_NVAPI_VKREFLEX=1 - unset DXVK_NVAPI_VKREFLEX - fi - - export DXVK_STATE_CACHE=1 - [[ "${DXVK_RESET_CACHE}" = 1 ]] && DXVK_STATE_CACHE=reset - - export NTFS_MODE=0 - [[ "${WINE_NTFS}" = 1 ]] && NTFS_MODE=1 - - export STAGING_SHARED_MEMORY=1 - export ULIMIT_SIZE=1048576 - export USE_BUILTIN_VKD3D=0 - - # Nvidia variables - [[ -e "/userdata/system/cache/nvidia" ]] || mkdir -p "/userdata/system/cache/nvidia" - export XDG_CACHE_HOME="/userdata/system/cache" - export __GL_SHADER_DISK_CACHE_SIZE=2147483648 - export __GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1 - export __GL_SHADER_DISK_CACHE_PATH="${XDG_CACHE_HOME}"/nvidia - - # While esync is deprecated, the ulimit is still important for Wine performance. - if ! ulimit -n "${ULIMIT_SIZE}" 2>/dev/null; then - echo "Warning: could not set file limit, which may impact performance." >&2 - fi -} +_logger = logging.getLogger('batocera-wine') -redist_install() { - WINEPREFIX=$1 - local i; local ii - if [[ -d "${USER_DIR}/exe" ]]; then - readarray -t i < <(find "${USER_DIR}/exe" -maxdepth 1 -type f -iname "*.exe" -printf "%p\n") - [[ "${#i[@]}" -eq 0 ]] && { rmdir "${USER_DIR}/exe"; echo "${FUNCNAME[0]}: Place only exe-files to ${USER_DIR}/exe" >&2; return 0; } - mkdir -p "${USER_DIR}/installed.exe" - - for file in "${i[@]}"; do - #We compare base-filename in lowercase only and execute the file stored in array - ii="$(basename "$file")"; ii="${ii,,}" - echo "Executing file $file" - case "${ii}" in - - "dxsetup.exe") - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" "${file}" /silent &>/dev/null || return 1 - "${WINESERVER}" -w - ;; - - "vcredist_x64_2005.exe" | "vcredist_x86_2005.exe" | \ - "vcredist_x64_2008.exe" | "vcredist_x86_2008.exe") - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" "${file}" /q &>/dev/null || return 1 - "${WINESERVER}" -w - ;; - - "vcredist_x64_2010.exe" | "vcredist_x86_2010.exe" | \ - "vcredist_x64_2012.exe" | "vcredist_x86_2012.exe" | \ - "vcredist_x64_2013.exe" | "vcredist_x86_2013.exe" | \ - "vcredist_x64_2015.exe" | "vcredist_x86_2015.exe" | \ - "vcredist_x64_2017.exe" | "vcredist_x86_2017.exe" | \ - "vcredist_x64_2019.exe" | "vcredist_x86_2019.exe") - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" "${file}" /quiet /qn /norestart &>/dev/null || return 1 - "${WINESERVER}" -w - ;; - - "vcredist_x64_2015_2019.exe" | "vcredist_x86_2015_2019.exe" | \ - "vcredist_x64_2015_2022.exe" | "vcredist_x86_2015_2022.exe" ) - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" "${file}" /quiet /qn /norestart &>/dev/null || return 1 - "${WINESERVER}" -w - ;; - - "oalinst.exe") - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" "${file}" /s &>/dev/null - "${WINESERVER}" -w - ;; - - *) - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" "${file}" &>/dev/null - "${WINESERVER}" -w - ;; - esac - echo "$(date '+%D %T') - Installed: '$file' --> ${WINEPREFIX}" | tee -a "${USER_DIR}/imports.log" - mv --backup=t "$file" "${USER_DIR}/installed.exe" - done - rmdir "${USER_DIR}/exe" || { echo "${FUNCNAME[0]}: Place only exe-files to ${USER_DIR}/exe" >&2; } - fi - return 0 -} -msi_install() { - WINEPREFIX=$1 - local i; local ii - ii="${USER_DIR}/msi" - if [[ -d "$ii" ]]; then - readarray -t i < <(find "$ii" -maxdepth 1 -type f -iname "*.msi" -printf "%p\n") - [[ "${#i[@]}" -eq 0 ]] && { rmdir "$ii"; echo "${FUNCNAME[0]}: Place only msi-files to $ii" >&2; return 0; } - mkdir -p "${USER_DIR}/installed.msi" - - for file in "${i[@]}"; do - echo "Executing file $file" - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${MSIEXEC}" -i "${file}" /quiet /qn /norestart &>/dev/null || return 1 - "${WINESERVER}" -w - echo "$(date '+%D %T') - Installed: '$file' --> $WINEPOINT" | tee -a "${USER_DIR}/imports.log" - mv --backup=t "$file" "${USER_DIR}/installed.msi" - done - rmdir "$ii" || { echo "${FUNCNAME[0]}: Place only msi-files to $ii" >&2; } - fi - return 0 -} +def _setting(system: str, game: str, key: str, /) -> str | None: + """A game option, as configgen resolves it: the game first, then the system, then + everything.""" + settings = UnixSettings(BATOCERA_CONF) -reg_install() { - WINEPREFIX=$1 - local i; local ii - ii="${USER_DIR}/regs" - if [[ -e "/var/run/rawinput.reg" ]]; then - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" regedit //?/unix/var/run/rawinput.reg &>/dev/null || return 1 - # Run WINE64 only if it is actually a distinct binary - if [[ "${WINE64}" != "${WINE}" ]]; then - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE64}" regedit //?/unix/var/run/rawinput.reg &>/dev/null || return 1 - fi - rm /var/run/rawinput.reg - fi - - if [[ "${VIRTUAL_DESKTOP}" = 1 ]]; then - # Set virtual desktop background color to solid black (RGB: 0 0 0) - WINEPREFIX=${WINEPREFIX} ${CMD_PREFIX} "${WINE}" reg add "HKEY_CURRENT_USER\Control Panel\Colors" /v "Background" /t REG_SZ /d "0 0 0" /f &>/dev/null - fi - - if [[ -d "$ii" ]]; then - readarray -t i < <(find "$ii" -maxdepth 1 -type f -iname "*.reg" -printf "%p\n") - [[ "${#i[@]}" -eq 0 ]] && { rmdir "$ii"; echo "${FUNCNAME[0]}: Place only reg-files to $ii" >&2; return 0; } - mkdir -p "${USER_DIR}/installed.regs" - - for file in "${i[@]}"; do - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" regedit //?/unix"${file}" &>/dev/null || return 1 - # Run WINE64 only if it is actually a distinct binary - if [[ "${WINE64}" != "${WINE}" ]]; then - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE64}" regedit //?/unix"${file}" &>/dev/null || return 1 - fi - echo "$(date '+%D %T') - Imported: '$file' --> $WINEPOINT" | tee -a "${USER_DIR}/imports.log" - mv --backup=t "$file" "${USER_DIR}/installed.regs" - done - rmdir "$ii" || { echo "${FUNCNAME[0]}: Place only reg-files to $ii" >&2; } - fi - - if [[ "${WINE_ENABLE_HIDRAW}" = 1 ]]; then - if ! grep -q "\"DisableHidraw\"=dword:00000000" "${WINEPOINT}/system.reg"; then - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" reg add "HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\winebus" /v "DisableHidraw" /t REG_DWORD /d 0 /f - waitWineServer 0 - fi - else - if ! grep -q "\"DisableHidraw\"=dword:00000001" "${WINEPOINT}/system.reg"; then - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" reg add "HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\winebus" /v "DisableHidraw" /t REG_DWORD /d 1 /f - waitWineServer 0 - fi - fi - return 0 -} + for name in (f'{system}["{game}"].{key}', f'{system}.{key}', f'global.{key}'): + value = settings.config.get('DEFAULT', name, fallback=None) -fonts_install() { - WINEPREFIX=$1 - local i; local ii - ii="${USER_DIR}/fonts" - if [[ -d "$ii" ]]; then - readarray -t i < <(find "$ii" -maxdepth 1 -type f \( -iname "*.ttf" -o -iname "*.ttc" \) -printf "%p\n") - [[ "${#i[@]}" -eq 0 ]] && { rmdir "$ii"; echo "${FUNCNAME[0]}: Place only ttc or ttf-files to $ii" >&2; return 0; } - mkdir -p "${USER_DIR}/installed.fonts" - - for file in "${i[@]}"; do - cp -i "$file" "${WINEPREFIX}/drive_c/windows/Fonts" || return 1 - echo "$(date '+%D %T') - Imported: '$file' --> ${WINEPREFIX}/drive_c/windows/Fonts" | tee -a "${USER_DIR}/imports.log" - mv --backup=t "$file" "${USER_DIR}/installed.fonts" - done - rmdir "$ii" || { echo "${FUNCNAME[0]}: Place only ttf and ttc-files to $ii" >&2; } - fi - return 0 -} + if value and value not in ('default', 'auto'): + return value -dxvk_install() { - export WINEDLLOVERRIDES="winemenubuilder.exe=" - WINEPREFIX=$1 - - # install dxvk only on system where it is available (aka, not x86) - [[ -e "/usr/wine/dxvk" ]] || return 0 - - DXVK=$(get_setting dxvk "${SYSTEM}" "${ROMGAMENAME}") - DXVK_HUD=$(get_setting dxvk_hud "${SYSTEM}" "${ROMGAMENAME}") - - if [[ "${DXVK_HUD}" = 1 ]]; then - export DXVK_HUD=1 - fi - - if [[ "${DXVK}" = 1 ]]; then - mkdir -p "${WINEPREFIX}/drive_c/windows/system32" "${WINEPREFIX}/drive_c/windows/syswow64" || return 1 - if [[ -e "/userdata/system/wine/dxvk" ]]; then - echo "Creating links using /userdata, Linux File System required !!!" - ln -sf "/userdata/system/wine/dxvk/x64/"{d3d12.dll,d3d12core.dll,d3d11.dll,d3d10core.dll,d3d9.dll,dxgi.dll,nvapi64.dll,nvofapi64.dll} "${WINEPREFIX}/drive_c/windows/system32" || return 1 - ln -sf "/userdata/system/wine/dxvk/x32/"{d3d8.dll,d3d12.dll,d3d12core.dll,d3d11.dll,d3d10core.dll,d3d9.dll,dxgi.dll,nvapi.dll} "${WINEPREFIX}/drive_c/windows/syswow64" || return 1 - else - echo "Creating links using /usr/wine/dxvk/, Linux File System required !!!" - ln -sf "/usr/wine/dxvk/x64/"{d3d12.dll,d3d12core.dll,d3d11.dll,d3d10core.dll,d3d9.dll,dxgi.dll,nvapi64.dll,nvofapi64.dll} "${WINEPREFIX}/drive_c/windows/system32" || return 1 - ln -sf "/usr/wine/dxvk/x32/"{d3d8.dll,d3d12.dll,d3d12core.dll,d3d11.dll,d3d10core.dll,d3d9.dll,dxgi.dll,nvapi.dll} "${WINEPREFIX}/drive_c/windows/syswow64" || return 1 - fi - else - mkdir -p "${WINEPREFIX}/drive_c/windows/system32" "${WINEPREFIX}/drive_c/windows/syswow64" || return 1 - echo "Creating links using ${DIR}/${WINE_VERSION}, Linux File System required !!!" - ln -sf "${WINE_LIB64_DIR}/x86_64-windows/"{d3d8.dll,d3d12.dll,d3d12core.dll,d3d11.dll,d3d10core.dll,d3d9.dll,dxgi.dll} "${WINEPREFIX}/drive_c/windows/system32" || return 1 - ln -sf "${WINE_LIB32_DIR}/i386-windows/"{d3d8.dll,d3d12.dll,d3d12core.dll,d3d11.dll,d3d10core.dll,d3d9.dll,dxgi.dll} "${WINEPREFIX}/drive_c/windows/syswow64" || return 1 - fi - - if [[ "${DXVK}" = 1 ]]; then - export DXVK_ASYNC=1 - export DXVK_CONFIG_FILE="/userdata/system/wine/dxvk.conf" - export WINEDLLOVERRIDES="${WINEDLLOVERRIDES};dxgi,d3d8,d3d9,d3d10core,d3d11,d3d12,d3d12core=n" - export DXVK_STATE_CACHE_PATH="/userdata/system/cache" - else - export DXVK_ASYNC=0 - export WINEDLLOVERRIDES="${WINEDLLOVERRIDES};dxgi,d3d8,d3d9,d3d10core,d3d11,d3d12,d3d12core=b" - fi - - if [[ "${NVAPI}" = 1 ]]; then - export DXVK_ENABLE_NVAPI=1 - export WINEDLLOVERRIDES="${WINEDLLOVERRIDES};nvapi,nvapi64=n" - else - export DXVK_ENABLE_NVAPI=0 - export WINEDLLOVERRIDES="${WINEDLLOVERRIDES};nvapi64,nvapi=" - fi + return None - return 0 -} -sandboxing_prefix() { - if [[ -d "${WINEPREFIX}/drive_c/users/steamuser" ]]; then - USERNAME=steamuser - fi +def _runner_from_settings(system: str, rom: Path, /) -> wine.Runner: + """The runner batocera.conf gives a rom, and the prefix it is played in. Nothing is + written: what it describes may not exist yet.""" + wanted_runner = _setting(system, rom.name, 'wine-runner') or _setting(system, rom.name, 'core') or '' + runner_name = wine.get_runner_name(wanted_runner) + prefix = wine.get_prefix_path(system, runner_name, rom) - if [[ -d "${WINEPREFIX}/drive_c/users/root" ]]; then - USERNAME=root - fi + # a prefix built from here is built the way the game would build it + enable_win32 = (_setting(system, rom.name, 'enable_win32') or '').lower() in Config.TRUE_VALUES - echo "Remove Symblink" - # replace some links by folders. - # don't create all folders in case links doesn't exist to not create both Music and My Music at the same time (old wine uses My Music, new wine uses Musics) - local DIR="" + arch = wine.wanted_prefix_arch(runner_name, enable_win32=enable_win32) - for DIR in "Downloads" "Documents" "My Documents" "Music" "My Music" "Pictures" "My Pictures" "Videos" "My Videos" "Templates" - do - if [[ -L "${WINEPREFIX}/drive_c/users/${USERNAME}/${DIR}" ]]; then - unlink "${WINEPREFIX}/drive_c/users/${USERNAME}/${DIR}" || return 1 - mkdir -p "${WINEPREFIX}/drive_c/users/${USERNAME}/${DIR}" || return 1 - fi - done + return wine.Runner.from_prefix(wanted_runner, prefix, arch=arch) - return 0 -} -saveFilesToUserdata() { - SYSTEM_SAVEDIR="/userdata/saves/${SYSTEM}/${1%.*}" - WINE_SAVEDIR="$2" - WINE_SAVEFILES="$3" - - [[ -z "${WINE_SAVEDIR}" && -z "${WINE_SAVEFILES}" ]] && return 0 #No argument in autorun.cmd -> return - - #Prepare for Savegames Files or Folders, SYSTEM_SAVEDIR and WINE_SAVEDIR are needed here - mkdir -p "${SYSTEM_SAVEDIR}" #Create dir to store our savegames in /userdata - mkdir -p "${WINEPOINT}/$(dirname "${WINE_SAVEDIR}")" #Create SAVEGAME_ROOT_DIR from there we can cp the files - requestFileSystem "${SYSTEM_SAVEDIR}" "${WINEPOINT}/$(dirname "${WINE_SAVEDIR}")" "${WINEPOINT}/${WINE_SAVEDIR}" #Check filesystem - pushd "${WINEPOINT}/$(dirname "${WINE_SAVEDIR}")" >/dev/null #Enter SAVEGAME_ROOT_DIR - WINE_SAVEDIR="$(basename "${WINE_SAVEDIR}")" #Savegamedir itself, content is copied/linked - echo "Preparing Savefiles: WINE_SAVEDIR: ${WINE_SAVEDIR} -> SYSTEM_SAVEDIR: ${SYSTEM_SAVEDIR}" - - if [[ -n "${WINE_SAVEDIR}" ]]; then - #Copy existing savedir content and link whole wine_savedir directory, check if dir is a symlink or not - if [[ ! -L "${WINE_SAVEDIR}" && -d "${WINE_SAVEDIR}" ]]; then - cp -r "${WINE_SAVEDIR}/." "${SYSTEM_SAVEDIR}" || { echo "${FUNCNAME[0]}: Error in copying files ${WINE_SAVEDIR} -> ${SYSTEM_SAVEDIR}" >&2; return 1; } - rm -rf "${WINE_SAVEDIR}" - fi - [[ ! -L "${WINE_SAVEDIR}" ]] && ln -s "${SYSTEM_SAVEDIR}" "${WINE_SAVEDIR}" - popd >/dev/null - - elif [[ -n "${WINE_SAVEFILES}" ]]; then - mkdir "${WINE_SAVEDIR}" - pushd "${WINE_SAVEDIR}" >/dev/null #You are working inside the game saves dir - #split the files list to array with ; delimiter - IFS=';' read -r -a SAVEFILES_ARRAY <<< "${WINE_SAVEFILES}" - for SAVEFILE in "${SAVEFILES_ARRAY[@]}"; do - if [[ -e "${SAVEFILE}" ]]; then - #if savefiles exist in the prefix and not yet in system save, move it once - if [[ ! -e "${SYSTEM_SAVEDIR}/${SAVEFILE}" && ! -L "${SAVEFILE}" ]]; then - mv -f "${SAVEFILE}" "${SYSTEM_SAVEDIR}/${SAVEFILE}" - else - rm "${SAVEFILE}" - fi - fi - ln -sf "${SYSTEM_SAVEDIR}/${SAVEFILE}" "${SAVEFILE}" - done - popd >/dev/null; popd >/dev/null #Clear stack - fi -} +def _get_resolved_rom_path(name: str | None, /) -> Path: + rom = Path(name).resolve() if name and name != '.' else Path.cwd() + + if not rom.exists(): + raise BatoceraException(f"'{rom}' doesn't exist") + + return rom + + +def _prepare_runner(system: str, rom: Path, /, *, require_new: bool = False) -> wine.Runner: + """ + The runner of a rom, with its prefix built if it isn't there yet: a .wine rom is a + prefix already, anything else has one of its own under /userdata/system/wine-bottles. -createWineDirectory() { - WINEPREFIX=$1 - # already created - [[ -e "${WINEPREFIX}" ]] && return 0 + require_new refuses a rom that is a prefix already, for the action whose whole point + is to make one. + """ + rom_extensions = ('.wine', '.pc', '.exe', '.wtgz', '.wsquashfs') - # please wait screen - bsod-wine& - BSODPID=$! + if rom.suffix.lower() not in rom_extensions: + raise BatoceraException( + f"'{rom}' is neither a wine prefix nor a game that runs in one ({', '.join(rom_extensions)})" + ) - mkdir -p "${WINEPREFIX}" || return 1 + runner = _runner_from_settings(system, rom) - # Workaround wine bottle creation issue with debug enabled - export WINEDLLOVERRIDES="winegstreamer=" + wine.log_filesystems(rom, runner.bottle_dir) - if ! WINEPREFIX=${WINEPREFIX} ${CMD_PREFIX} "${WINE}" hostname; then - rm -rf "${WINEPREFIX}" - kill -15 "${BSODPID}" - echo "+++ Failed initialising ${WINEPREFIX} +++" - return 1 - fi + if runner.bottle_dir == rom: + if require_new: + raise BatoceraException(f"'{rom}' is already a prefix") + elif not runner.bottle_dir.is_dir(): + _logger.info('creating the prefix %s', runner.bottle_dir) + runner.create_bottle() + else: + _logger.info('using the prefix %s', runner.bottle_dir) - kill -15 "${BSODPID}" + return runner + +def _do_stop(args: argparse.Namespace, /) -> int: + wine.stop_all() return 0 -} -getWine_var() { - WINEPOINT=$1 - WINEVAR=$2 - WINEVALUE=$3 - - if [[ -e "${WINEPOINT}/autorun.cmd" ]]; then - VAL=$(cat "${WINEPOINT}/autorun.cmd" | dos2unix | grep "^${WINEVAR}=" | sed -e s+"^${WINEVAR}="+""+ | head -1) - if [[ -n "${VAL}" ]]; then - echo "${VAL}" - else - echo "${WINEVALUE}" - fi - else - echo "${WINEVALUE}" - fi -} -play_wine() { - echo "play_wine" - GAMENAME="$1" - WINEPOINT="$2" - - WINE_CMD=$(getWine_var "${WINEPOINT}" "CMD" "explorer") - WINE_DIR=$(getWine_var "${WINEPOINT}" "DIR" "") - WINE_LANG=$(getWine_var "${WINEPOINT}" "LANG" "") - WINE_ENV=$(getWine_var "${WINEPOINT}" "ENV" "") - WINE_SAVEDIR=$(getWine_var "${WINEPOINT}" "SAVEDIR" "") - WINE_SAVEFILES=$(getWine_var "${WINEPOINT}" "SAVEFILES" "") - - setPrefixArch "${WINEPOINT}" "${WINE_VERSION}" - wine_options "${WINEPOINT}" - redist_install "${WINEPOINT}" || return 1 - msi_install "${WINEPOINT}" || return 1 - reg_install "${WINEPOINT}" || return 1 - fonts_install "${WINEPOINT}" || return 1 - sandboxing_prefix "${WINEPOINT}" || return 1 - dxvk_install "${WINEPOINT}" || return 1 - saveFilesToUserdata "${ROMGAMENAME}" "${WINE_SAVEDIR}" "${WINE_SAVEFILES}" || return 1 - - if [[ -n "${WINE_LANG}" ]]; then - (cd "${WINEPOINT}/${WINE_DIR}" && LC_ALL=${WINE_LANG} WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - else - (cd "${WINEPOINT}/${WINE_DIR}" && WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - fi - waitWineServer 0 -} +def _do_tricks(args: argparse.Namespace, /) -> int: + if not args.arguments: + raise BatoceraException('Name at least one trick to apply, see https://github.com/Winetricks/winetricks') -play_pc() { - echo "play_pc" - GAMENAME="$1" - WINEPOINT="$2" - - WINE_CMD=$(getWine_var "${GAMENAME}" "CMD" "explorer") - WINE_DIR=$(getWine_var "${GAMENAME}" "DIR" "") - WINE_LANG=$(getWine_var "${GAMENAME}" "LANG" "") - WINE_ENV=$(getWine_var "${GAMENAME}" "ENV" "") - WINE_SAVEDIR=$(getWine_var "${GAMENAME}" "SAVEDIR" "") - WINE_SAVEFILES=$(getWine_var "${GAMENAME}" "SAVEFILES" "") - - setPrefixArch "${WINEPOINT}" "${WINE_VERSION}" - wine_options "${WINEPOINT}" - createWineDirectory "${WINEPOINT}" || return 1 - redist_install "${WINEPOINT}" || return 1 - msi_install "${WINEPOINT}" || return 1 - reg_install "${WINEPOINT}" || return 1 - fonts_install "${WINEPOINT}" || return 1 - sandboxing_prefix "${WINEPOINT}" || return 1 - dxvk_install "${WINEPOINT}" || return 1 - saveFilesToUserdata "${ROMGAMENAME}" "${WINE_SAVEDIR}" "${WINE_SAVEFILES}" || return 1 - - env - if [[ -n "${WINE_LANG}" ]]; then - (cd "${GAMENAME}/${WINE_DIR}" && LC_ALL=${WINE_LANG} WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - else - (cd "${GAMENAME}/${WINE_DIR}" && WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - fi - waitWineServer 0 -} + runner = _prepare_runner(args.system, _get_resolved_rom_path(args.game)) -trick_wine() { - WINEPOINT="$1" - shift - TRICK=("$@") - - if [[ -e "${WINETRICKS}" ]]; then - echo "Winetricks is installed" - else - echo "Winetricks is downloading" - wget -O "${WINETRICKS}" "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks" &>/dev/null - chmod +x "${WINETRICKS}" - echo "Winetricks is now installed" - fi - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINETRICKS}" "${TRICK[@]}" -} + # winetricks looks at the windows directories of the prefix to tell its + # architecture, and refuses to work on a 32bit prefix that still has a syswow64 + if runner.arch == 'win32' and (syswow64 := runner.bottle_dir / 'drive_c' / 'windows' / 'syswow64').is_dir(): + _logger.info('removing %s from the 32bit prefix', syswow64) + shutil.rmtree(syswow64) -#play_iso() { -# GAMENAME=$1 -# # TODO -#} - -play_exe() { - GAMENAME="$1" - WINEPOINT="$2" - - setPrefixArch "${WINEPOINT}" "${WINE_VERSION}" - wine_options "${WINEPOINT}" - createWineDirectory "${WINEPOINT}" || return 1 - redist_install "${WINEPOINT}" || return 1 - msi_install "${WINEPOINT}" || return 1 - reg_install "${WINEPOINT}" || return 1 - fonts_install "${WINEPOINT}" || return 1 - sandboxing_prefix "${WINEPOINT}" || return 1 - dxvk_install "${WINEPOINT}" || return 1 - - (cd "${ROMBASEDIR}" && WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" ${VDESKTOP} "${ROMGAMENAME}") - waitWineServer 0 -} + _logger.info('applying %s to %s', ' '.join(args.arguments), runner.bottle_dir) -play_winetgz() { - echo "play_winetgz" - GAMENAME="$1" - WINEPOINT="$2" - - setPrefixArch "${WINEPOINT}" "${WINE_VERSION}" - wine_options "${WINEPOINT}" - if [[ ! -e "${WINEPOINT}" ]]; then - createWineDirectory "${WINEPOINT}" || return 1 - (cd "${WINEPOINT}" && gunzip -c "${GAMENAME}" | tar xf -) || return 1 - fi - - WINE_CMD=$(getWine_var "${WINEPOINT}" "CMD" "explorer") - WINE_DIR=$(getWine_var "${WINEPOINT}" "DIR" "") - WINE_LANG=$(getWine_var "${WINEPOINT}" "LANG" "") - WINE_ENV=$(getWine_var "${WINEPOINT}" "ENV" "") - WINE_SAVEDIR=$(getWine_var "${WINEPOINT}" "SAVEDIR" "") - WINE_SAVEFILES=$(getWine_var "${WINEPOINT}" "SAVEFILES" "") - - redist_install "${WINEPOINT}" || return 1 - msi_install "${WINEPOINT}" || return 1 - reg_install "${WINEPOINT}" || return 1 - fonts_install "${WINEPOINT}" || return 1 - sandboxing_prefix "${WINEPOINT}" || return 1 - dxvk_install "${WINEPOINT}" || return 1 - saveFilesToUserdata "${ROMGAMENAME}" "${WINE_SAVEDIR}" "${WINE_SAVEFILES}" || return 1 - - if [[ -n "${WINE_LANG}" ]]; then - (cd "${WINEPOINT}/${WINE_DIR}" && LC_ALL=${WINE_LANG} WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - else - (cd "${WINEPOINT}/${WINE_DIR}" && WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - fi - waitWineServer 0 -} + # winetricks downloads, reports and asks, it is given the terminal it was called from + runner.run_winetricks(args.arguments, capture_output=False) -# Function to safely unmount a mount point with multiple attempts -safe_umount() { - local mount_point="$1" - local attempts=3 - - for ((i=1; i<=attempts; i++)); do - # Try lazy unmount first - umount -l "${mount_point}" && return 0 - # If lazy unmount fails, try force unmount - umount -f "${mount_point}" && return 0 - # If that fails, try recursive force unmount - umount -f -R "${mount_point}" && return 0 - sleep 1 - done - - # If all attempts fail, we return an error - echo "Error: Failed to unmount ${mount_point} after ${attempts} attempts" >&2 - return 1 -} + return 0 -play_squashfs() { - echo "play_squashfs" - GAMENAME="$1" - WINEPOINT="$2" - setPrefixArch "${WINEPOINT}" "${WINE_VERSION}" - wine_options "${WINEPOINT}" - SQUASHFSPOINT="/var/run/wine/squashfs_${ROMGAMENAME}" - SAVEPOINT="$3" - WORKPOINT="$4" - - # Attempt to unmount any existing mount points before starting - [[ -d "${WINEPOINT}" ]] && safe_umount "${WINEPOINT}" - [[ -d "${SQUASHFSPOINT}" ]] && safe_umount "${SQUASHFSPOINT}" - - # Remove directories if they exist - [[ -d "${SQUASHFSPOINT}" ]] && rm -rf "${SQUASHFSPOINT}" - [[ -d "${WORKPOINT}" ]] && rm -rf "${WORKPOINT}" - [[ -d "${WINEPOINT}" ]] && rm -rf "${WINEPOINT}" - - # Create necessary fresh directories - mkdir -p "${SAVEPOINT}" || return 1 - mkdir -p "${WORKPOINT}" || return 1 - mkdir -p "${WINEPOINT}" || return 1 - mkdir -p "${SQUASHFSPOINT}" || return 1 - - # Mount squashfs - if ! mount "${GAMENAME}" "${SQUASHFSPOINT}"; then - [[ -d "${SQUASHFSPOINT}" ]] && rm -rf "${SQUASHFSPOINT}" - [[ -d "${WORKPOINT}" ]] && rm -rf "${WORKPOINT}" - [[ -d "${WINEPOINT}" ]] && rm -rf "${WINEPOINT}" - return 1 - fi - - # Mount overlay - if ! mount -t overlay -o rw,lowerdir="${SQUASHFSPOINT}",upperdir="${SAVEPOINT}",workdir="${WORKPOINT}",redirect_dir=on overlay "${WINEPOINT}"; then - safe_umount "${SQUASHFSPOINT}" - [[ -d "${SQUASHFSPOINT}" ]] && rm -rf "${SQUASHFSPOINT}" - [[ -d "${WORKPOINT}" ]] && rm -rf "${WORKPOINT}" - [[ -d "${WINEPOINT}" ]] && rm -rf "${WINEPOINT}" - return 1 - fi - - WINE_CMD=$(getWine_var "${WINEPOINT}" "CMD" "explorer") - WINE_DIR=$(getWine_var "${WINEPOINT}" "DIR" "") - WINE_LANG=$(getWine_var "${WINEPOINT}" "LANG" "") - WINE_ENV=$(getWine_var "${WINEPOINT}" "ENV" "") - WINE_SAVEDIR=$(getWine_var "${WINEPOINT}" "SAVEDIR" "") - WINE_SAVEFILES=$(getWine_var "${WINEPOINT}" "SAVEFILES" "") - - reg_install "${WINEPOINT}" || return 1 - fonts_install "${WINEPOINT}" || return 1 - dxvk_install "${WINEPOINT}" || return 1 - saveFilesToUserdata "${ROMGAMENAME}" "${WINE_SAVEDIR}" "${WINE_SAVEFILES}" || return 1 - - if [[ -n "${WINE_LANG}" ]]; then - (cd "${WINEPOINT}/${WINE_DIR}" && LC_ALL=${WINE_LANG} WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - else - (cd "${WINEPOINT}/${WINE_DIR}" && WINEPREFIX=${WINEPOINT} eval "${WINE_ENV}" ${CMD_PREFIX} "${WINE}" ${VDESKTOP} ${WINE_CMD}) - fi - waitWineServer 0 -} -createAutorunCmd() { - WINEPOINT="$1" - AUTORUN_FILEMASK="$2" - local i; local ii - - #Improved version of creating a CMD-file - the FILTER array can handle RegEx like * and ? - grep strips every line with a #-literal - #Using RexEx extensions from user created files, ~/../roms/windows_installers is prefered over ~/../saves/windows_installers - i="/userdata/roms/windows_installers/autorun-regex.txt" - ii="/userdata/saves/windows_installers/autorun-regex.txt" - [[ -e "$i" ]] || { [[ -e "$ii" ]] && i="$ii"; } && { dos2unix -k -q "$i"; readarray -t AUTORUN_FILTER < <(grep -v -E "^[[:space:]*]|[#]|^[[:space:]]*$" "$i"); } - # Pre-setted filter to exclude some standard files from created WINEPREFIX - AUTORUN_FILTER+=("^.*/Windows Media Player/.*$" "^.*/Windows NT/.*$" "^.*/Internet Explorer/.*$" "^.*/drive_c/windows/.*$" - "^.*/[Uu]nins[[:alnum:]]{0,6}\.exe$" "^.*/[Ii]nstall(..)?\.exe$" "^.*/[Ss]etup\.exe$" "^.*/[Uu]nwise(..)?\.exe$") - - #We search in WINEPOINT dir for exes and I assume it's somewhere installed in Program Files, or Program Files(x86) - pushd "$WINEPOINT" > /dev/null - readarray -t AUTORUN_FOUNDEXE < <(find ${AUTORUN_FILEMASK} -type f -iname "*.exe" -printf "%p\n" | sort -n) - - for i in "${AUTORUN_FILTER[@]}"; do - for ii in "${!AUTORUN_FOUNDEXE[@]}"; do - [[ "${AUTORUN_FOUNDEXE[$ii]}" =~ $i ]] && unset AUTORUN_FOUNDEXE[$ii] - done - done - - AUTORUN_FOUNDEXE=("${AUTORUN_FOUNDEXE[@]}") #Renew array after unset elements - unset AUTORUN_FILTER - popd > /dev/null - [[ "${FUNCNAME[1]}" == "main" ]] && return 0 #Don't create autorun.cmd if used parameter "autorun" - - if [[ ${#AUTORUN_FOUNDEXE[@]} -eq 1 ]]; then - ( - echo "DIR=$(dirname "${AUTORUN_FOUNDEXE[0]}")" - echo "CMD=\"$(basename "${AUTORUN_FOUNDEXE[0]}")\"" - ) > "${WINEPOINT}/autorun.cmd" - else - ( - echo "#DIR=drive_c/Program Files/myprogram" - echo "#CMD=start.exe" - ) > "${WINEPOINT}/autorun.cmd" - fi +def _do_createprefix(args: argparse.Namespace, /) -> int: + runner = _prepare_runner(args.system, _get_resolved_rom_path(args.game), require_new=True) + + print(runner.bottle_dir) + return 0 -} -install_exe_msi() { - #We need to select annother install type for MSI - #ROMBASEDIR is here /userdata/roms/windows_installer and ROMGAMENAME is the executable only - GAMEEXT="$1" - GAMENAME="$2" - WINEPOINT="$3" - setPrefixArch "${WINEPOINT}" "${WINE_VERSION}" - createWineDirectory "${WINEPOINT}" +def _do_wine2squashfs(args: argparse.Namespace, /) -> int: + rom = _get_resolved_rom_path(args.game) + # the archive is written beside the prefix it was made from, wherever that is + target = rom.with_name(f'{rom.stem}.wsquashfs') - [[ "${GAMEEXT}" == "exe" ]] && WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" "${GAMENAME}" - [[ "${GAMEEXT}" == "msi" ]] && WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${MSIEXEC}" -i "${GAMENAME}" - waitWineServer 0 - createAutorunCmd "${WINEPOINT}" "drive_c/P*" -} + if target.exists(): + raise BatoceraException(f"'{target}' already exists") -install_iso() { - GAMENAME="$1" - WINEPOINT="$2" - GAMEISOMOUNT="/var/run/wine/${ROMGAMENAME}.cdrom" + _logger.info('squashing %s into %s', rom, target) + subprocess.run(['mksquashfs', str(rom), str(target), '-noappend', '-comp', 'zstd'], check=True) - mkdir -p "${GAMEISOMOUNT}" || return 1 - if ! mount -t iso9660 "${GAMENAME}" "${GAMEISOMOUNT}"; then - if ! mount -t udf "${GAMENAME}" "${GAMEISOMOUNT}"; then - rmdir "${GAMEISOMOUNT}" - return 1 - fi - fi + return 0 - setPrefixArch "${WINEPOINT}" "${WINE_VERSION}" - createWineDirectory "${WINEPOINT}" - if mkdir -p "${WINEPOINT}/dosdevices" && rm -f "${WINEPOINT}/dosdevices/d:" && ln -sf "${GAMEISOMOUNT}" "${WINEPOINT}/dosdevices/d:"; then - WINEPREFIX=${WINEPOINT} ${CMD_PREFIX} "${WINE}" explorer "d:" - rm -f "${WINEPOINT}/dosdevices/d:" - fi +def _do_wine2winetgz(args: argparse.Namespace, /) -> int: + rom = _get_resolved_rom_path(args.game) + target = ROMS / args.system / f'{rom.stem}.wtgz' - waitWineServer 0 - createAutorunCmd "${WINEPOINT}" "drive_c/P*" + if target.exists(): + raise BatoceraException(f"'{target}' already exists") -} + _logger.info('compressing %s into %s', rom, target) + + # the prefix is packed from inside, so that unpacking it yields a prefix again + with tarfile.open(target, 'w:gz') as tar: + for entry in sorted(rom.iterdir()): + tar.add(entry, arcname=entry.name) -wine2squashfs() { - #GAMENAME is an .wine directory here, compress DIR -> FILE.wsquasfs - GAMEDIR="$1" - SQUASHFSFILE="$2" - mksquashfs "${GAMEDIR}" "${SQUASHFSFILE}" -comp zstd || return 1 - echo "Created file: $(basename "${SQUASHFSFILE}") <- Dir: ${GAMEDIR}" return 0 -} -wine2winetgz() { - GAMEDIR="$1" - WINETGZFILE="$2" - echo "Building compressed file: $(basename "${WINETGZFILE}") <- ${GAMEDIR}" - (cd "${GAMEDIR}" && tar cf - * | gzip -c > "${WINETGZFILE}") && echo "File: $(basename "${WINETGZFILE}") build..." || return 1 + +def _do_autorun(args: argparse.Namespace, /) -> int: + directory = _get_resolved_rom_path(args.game) + file_mask = args.arguments[0] if args.arguments else '.' + + executables = wine.find_game_executables(directory, file_mask) + + if args.action == 'autorun-count': + print(len(executables)) + return 0 if executables else 2 + + if not executables: + _logger.error("no windows executable found in '%s/%s'", directory, file_mask) + return 2 + + if args.action == 'autorun-list': + for executable in executables: + print(executable) + return 0 + + chosen = executables[0] + + if len(executables) > 1: + print(f'Found {len(executables)} executables in {directory}') + print('0) Enter 0 or CTRL+C to abort the creation of autorun.cmd') + + for index, executable in enumerate(executables, start=1): + print(f'{index}) {executable.name} --> {executable.parent}') + + try: + answer = int(input('\nSelect the entry to write to autorun.cmd: ')) + except (EOFError, KeyboardInterrupt, ValueError): + return 2 + + if not 1 <= answer <= len(executables): + return 2 + + chosen = executables[answer - 1] + + wine.write_autorun_cmd(directory, chosen) + print(f'Written: {directory / "autorun.cmd"}') + return 0 -} -requestFileSystem() { - #@DEVS Caller function, current function is always inserted as element 0, called one is therefore 1 - local i ii - echo "------ ${FUNCNAME[0]} called from ${FUNCNAME[1]} ------" - for i in "$@"; do - [ ! -e "$i" ] && echo "${FUNCNAME[1]}: $i does not exists" && continue - ii=$(df -PTh "$i" | awk 'END{print $2}') - [ -d "$i" ] && printf "%s: Directory %s uses filesystem %s" "${FUNCNAME[1]}" "$i" "$ii" - [ -f "$i" ] && printf "%s: File %s uses filesystem %s" "${FUNCNAME[1]}" "$i" "$ii" - [ -L "$i" ] && printf "\n\t \\----> Symlinked to: " && readlink "$i" || echo - done -} -cleanAndExit() { - RESNEW=$(batocera-resolution currentMode) - if [[ "${RESNEW}" != "${G_RESCUR}" ]]; then - batocera-resolution setMode "${G_RESCUR}" - fi - - if [[ -e "${GST_REGISTRY_1_0}" ]]; then - rm -f "${GST_REGISTRY_1_0}" - fi - - case "${GAMEEXT,,}" in - "iso") - # try to clean the cdrom - [[ -n "${GAMEISOMOUNT}" ]] && [[ -d "${GAMEISOMOUNT}" ]] && safe_umount "${GAMEISOMOUNT}" || return 1 - [[ -n "${GAMEISOMOUNT}" ]] && [[ -d "${GAMEISOMOUNT}" ]] && rm -rf "${GAMEISOMOUNT}" - ;; - "wsquashfs") - # Safely unmount and clean up - [[ -n "${WINEPOINT}" ]] && [[ -d "${WINEPOINT}" ]] && safe_umount "${WINEPOINT}" || return 1 - [[ -n "${SQUASHFSPOINT}" ]] && [[ -d "${SQUASHFSPOINT}" ]] && safe_umount "${SQUASHFSPOINT}" || return 1 - - # Remove directories if they exist - [[ -n "${SQUASHFSPOINT}" ]] && [[ -d "${SQUASHFSPOINT}" ]] && rm -rf "${SQUASHFSPOINT}" - [[ -n "${WORKPOINT}" ]] && [[ -d "${WORKPOINT}" ]] && rm -rf "${WORKPOINT}" - [[ -n "${WINEPOINT}" ]] && [[ -d "${WINEPOINT}" ]] && rm -rf "${WINEPOINT}" - ;; - esac - echo "WineServer was ${SECONDS}s active" - return $? +_ACTIONS = { + 'stop': _do_stop, + 'tricks': _do_tricks, + 'createprefix': _do_createprefix, + 'wine2squashfs': _do_wine2squashfs, + 'wine2winetgz': _do_wine2winetgz, + 'autorun': _do_autorun, + 'autorun-list': _do_autorun, + 'autorun-count': _do_autorun, } -init_wine() { - # Check the system's display mode once at the start - SYSTEM_DISPLAY_MODE=$(batocera-resolution getDisplayMode) - - ## Wine detection - WINE_RUNNER="$(/usr/bin/batocera-settings-get windows.wine-runner)" - [[ -z "$WINE_RUNNER" ]] && WINE_RUNNER="wine-tkg" - - WINE_VERSION="$(get_setting wine-runner "${SYSTEM}" "${ROMGAMENAME}")" - # to help with the transition from previous runners. - [[ -z "$WINE_VERSION" ]] && WINE_VERSION="$(get_setting core "${SYSTEM}" "${ROMGAMENAME}" || echo "${WINE_RUNNER}")" - - if [[ "${WINE_VERSION}" == "lutris" ]]; then - WINE_VERSION="wine-tkg" - elif [[ "${WINE_VERSION}" == "proton" ]]; then - WINE_VERSION="wine-proton" - fi - echo "*** Chosen WINE runner is ${WINE_VERSION} ***" - - ## Wine executables - DIR="$(find_wine_dir "$WINE_VERSION")" - if [[ $? -eq 0 ]]; then - WINE_VERSION="$(update_wine_version "$WINE_VERSION")" - else - echo "can't find WINE version ${WINE_VERSION} directory, you should change the runner" - exit 1 - fi - - echo "*** Directory checks complete, WINE runner is ${WINE_VERSION} ***" - USER_DIR="/userdata/system/wine" - # Check 32-bit wine path - if [[ -e "${DIR}/${WINE_VERSION}/lib/wine/i386-unix/wine" ]]; then - WINE="${DIR}/${WINE_VERSION}/lib/wine/i386-unix/wine" - else - WINE="${DIR}/${WINE_VERSION}/bin/wine" - fi - # Check 64-bit wine64 path, with fallback to WINE if not found - if [[ -e "${DIR}/${WINE_VERSION}/lib/wine/x86_64-unix/wine64" ]]; then - WINE64="${DIR}/${WINE_VERSION}/lib/wine/x86_64-unix/wine64" - elif [[ -e "${DIR}/${WINE_VERSION}/bin/wine64" ]]; then - WINE64="${DIR}/${WINE_VERSION}/bin/wine64" - else - WINE64="${WINE}" - fi - - WINESERVER="${DIR}/${WINE_VERSION}/bin/wineserver" - MSIEXEC="${DIR}/${WINE_VERSION}/bin/msiexec" - WINETRICKS="${DIR}/winetricks" - - # Check lib64 directory - if [[ -e "${DIR}/${WINE_VERSION}/lib64/wine" ]]; then - WINE_LIB64_DIR="${DIR}/${WINE_VERSION}/lib64/wine" - else - WINE_LIB64_DIR="${DIR}/${WINE_VERSION}/lib/wine" - fi - # Check lib32 directory - if [[ -e "${DIR}/${WINE_VERSION}/lib32/wine" ]]; then - WINE_LIB32_DIR="${DIR}/${WINE_VERSION}/lib32/wine" - else - WINE_LIB32_DIR="${DIR}/${WINE_VERSION}/lib/wine" - fi - - ## Export Wine libs - PATH=$PATH:PATH=$PATH:${DIR}/${WINE_VERSION}/bin - export LD_LIBRARY_PATH="/lib32:${WINE_LIB32_DIR}/i386-unix:/lib:/usr/lib:${WINE_LIB64_DIR}/x86_64-unix" - export GST_PLUGIN_SYSTEM_PATH_1_0="/usr/lib/gstreamer-1.0:/lib32/gstreamer-1.0" - export GST_REGISTRY_1_0="/userdata/system/.cache/gstreamer-1.0/registry.x86_64.bin:/userdata/system/.cache/gstreamer-1.0/registry..bin" - export LIBGL_DRIVERS_PATH="/lib32/dri:/usr/lib/dri" - export WINEDLLPATH="${WINE_LIB32_DIR}/i386-windows:${WINE_LIB64_DIR}/x86_64-windows" - # hum pw 0.2 and 0.3 are hardcoded, not nice - export SPA_PLUGIN_DIR="/usr/lib/spa-0.2:/lib32/spa-0.2" - export PIPEWIRE_MODULE_DIR="/usr/lib/pipewire-0.3:/lib32/pipewire-0.3" - - # safe old resolution, for bringing it back properly after WINE closes, cleanAndExit() - G_RESCUR=$(batocera-resolution currentMode) -} -###### MAIN ####### -trap stopWineServer SIGHUP SIGINT SIGTERM - -#Init WINE Folders and Extension only if parameters are correct and a system is setted -[[ -z "${SYSTEM}" ]] && SYSTEM="~NO SYSTEM~" -[[ -z "${ACTION}" ]] && ACTION="~NO ACTION~" - -case "${ACTION}" in - "stop") - echo "Stop called from Sunbeam: Outside World" - PID=$(pgrep -f -o $0) - kill -1 $(pgrep -P $PID) - kill -1 $PID - exit 0 - ;; - -# case selections will provide 2 variables here, GAMENAME and WINEPOINT - "play") - init_wine - case "${GAMEEXT,,}" in - "wine") - requestFileSystem "${GAMENAME}" - play_wine "${GAMENAME}" "${GAMENAME}" - ;; - "pc") - requestFileSystem "${GAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - play_pc "${GAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - ;; - "exe") - requestFileSystem "${GAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - play_exe "${GAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - ;; -# "iso") -# play_iso "${GAMENAME}" -# ;; - "wsquashfs") - #Arguments, ROMNAME, WINEPREFIX as squashfs, SAVEDIR, WORKDIR - requestFileSystem "${GAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - play_squashfs "${GAMENAME}" "/var/run/wine/${ROMGAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" "${WINE_BOTTLE_DIR}/${ROMGAMENAME}.work" - ;; - "wtgz") - requestFileSystem "${GAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - play_winetgz "${GAMENAME}" "${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - ;; - *) - echo "unknown extension ${GAMEEXT}" >&2 - esac - ;; - - "install") - # Sync windows_installers and windows, so setting from installer section will be used for the installed program - # This method is very safe and it syncs all settings between the 2 systems, $INST_ROMGAMENAME is used for an unique filename - INST_ROMGAMENAME="$(date +%y%m%d-%H%M%S)_${ROMGAMENAME%.*}.wine" - - while read i; do - val_key=$(batocera-settings-get "$i") - printf "Converted ${i} -> " - i="${i/${SYSTEM}_installers/${SYSTEM}}" - i="${i/${ROMGAMENAME}/${INST_ROMGAMENAME}}" - batocera-settings-set "${i}" "$val_key" - echo "${i} with value ${val_key}" - done < <(grep -Eo "^${SYSTEM}_installers(\[\"${ROMGAMENAME}\"\])?[.][^=]*" /userdata/system/batocera.conf) - unset i val_key - - init_wine && requestFileSystem "${GAMENAME}" - case "${GAMEEXT,,}" in - "exe"|"msi") - #INST_ROMGAMENAME -> Winepoint with stripped extension, add current date_time to avoid duplicates - install_exe_msi "${GAMEEXT,,}" "${GAMENAME}" "${G_ROMS_DIR}/${INST_ROMGAMENAME}" - ;; - "iso") - #INST_ROMGAMENAME -> Parsing Gamename, Winepoint with stripped extension, add current date_time to avoid duplicates - install_iso "${GAMENAME}" "${G_ROMS_DIR}/${INST_ROMGAMENAME}" - ;; - *) - echo "unknown extension ${GAMEEXT}" >&2 - esac - ;; - - "tricks"|"createprefix") - [[ "${GAMENAME}" == "." ]] && { GAMENAME="$PWD"; GAMEEXT="${GAMENAME##*.}"; } - [[ -e "${GAMENAME}" ]] && GAMENAME="$(realpath "${GAMENAME}")" || exit 1 - init_wine && requestFileSystem "${GAMENAME}" - case "${GAMEEXT,,}" in - "wine") - [[ "${ACTION}" == "createprefix" ]] && { echo "error: '${GAMENAME}' is already a prefix"; exit 1; } - l_PREFIX="${GAMENAME}" - setPrefixArch "${GAMENAME}" "${WINE_VERSION}" "${ACTION}" - ;; - "wtqz"|"pc"|"exe"|"wsquashfs") - l_PREFIX="${WINE_BOTTLE_DIR}/${WINE_VERSION}/${ROMGAMENAME}.wine" - setPrefixArch "${l_PREFIX}" "${WINE_VERSION}" "${ACTION}" - test -d "${l_PREFIX}" && echo "WINEPREFIX: '$_' already found!" || { createWineDirectory "$_" && echo "WINEPREFIX: '$_' created!"; } - ;; - *) echo "Fullpath for game needed, or extension is unknown ... Exit now!"; exit 1 - esac - - if [[ -n "${TRICK}" && "${ACTION}" == "tricks" ]]; then - shift 3 # heap additional arguments for daisychained tricks or cli commands - echo "${FUNCNAME[0]}: Applying tricks: $@ to '${l_PREFIX}'" - trick_wine "${l_PREFIX}" "$@" - fi - unset l_PREFIX - ;; - - "wine2squashfs") - #Parsing Gamename, location and name of compressed file - wine2squashfs "${GAMENAME}" "${G_ROMS_DIR}/${ROMGAMENAME%.*}.wsquashfs" - exit $? - ;; - - "wine2winetgz") - wine2winetgz "${GAMENAME}" "${G_ROMS_DIR}/${ROMGAMENAME%.*}.wtgz" - exit $? - ;; - - "autorun"|"autorun-list"|"autorun-count") - # Create autorunfile, works only in SSH mode, recommended is arg1=gamepath, arg2=searchmask - # It's held small: "batocera-wine windows autorun . bin" will search current directory in dir bin - [[ -z "${GAMENAME}" ]] && GAMENAME="$PWD" - [[ -z "${AUTORUN_FILEMASK}" ]] && AUTORUN_FILEMASK="." - pushd "${GAMENAME}" &> /dev/null || { echo "Error: Can't enter dir '${GAMENAME}'" >&2; exit 1; } - GAMENAME="$PWD" - createAutorunCmd "${GAMENAME}" "${AUTORUN_FILEMASK}" || exit 1 - [[ ${#AUTORUN_FOUNDEXE[@]} -eq 0 ]] && { ! [[ "${ACTION}" == "autorun-count" ]] && echo "Error: No windows executable found in '${GAMENAME}/${AUTORUN_FILEMASK}'" >&2 || echo 0; exit 2; } - #show only list of exes if autorun-list/count is set otherwise... - [[ "${ACTION}" == "autorun-list" ]] && { printf '%s\n' "${AUTORUN_FOUNDEXE[@]}"; exit 0; } - [[ "${ACTION}" == "autorun-count" ]] && { echo ${#AUTORUN_FOUNDEXE[@]}; exit 0; } - #...show list with entries numbers to select - if [[ ${#AUTORUN_FOUNDEXE[@]} -gt 1 ]]; then - echo "Found ${#AUTORUN_FOUNDEXE[@]} files in ${GAMENAME}" - echo "0) Enter 0 or CTRL+C to abort creation of autorun.cmd" - for i in "${AUTORUN_FOUNDEXE[@]}"; do - let ii++ - echo -e "$ii) $(basename "$i") --> $(dirname "$i")" - done - echo; read -p "Select entry to write to autorun.cmd: " ii - else - ii=1 - fi - [[ $ii -eq 0 ]] && exit 2 #0 selected, abort - [[ -e autorun.cmd ]] && echo "File exists: Creating Backup!" && mv --backup=t autorun.cmd autorun.cmd.bak - ( - echo "DIR=$(dirname "${AUTORUN_FOUNDEXE[$ii-1]}")" - echo "CMD=\"$(basename "${AUTORUN_FOUNDEXE[$ii-1]}")\"" - ) > autorun.cmd - echo "Written: ${GAMENAME}/autorun.cmd" - popd > /dev/null - exit 0 - ;; - - *) - echo "For system <${SYSTEM}> action <${ACTION}> detected" >&2 - echo - echo "${0} windows play .iso" >&2 - echo "${0} windows play .exe" >&2 - echo "${0} windows play .pc" >&2 - echo "${0} windows play .wine" >&2 - echo "${0} windows play .wsquashfs" >&2 - echo "${0} windows play .wtgz" >&2 - echo "${0} windows install .exe" >&2 - echo "${0} windows install .iso" >&2 - echo "${0} windows install .msi" >&2 - echo "${0} windows tricks .wine directplay" >&2 - echo "${0} windows createprefix " >&2 - echo "${0} windows wine2squashfs " >&2 - echo "${0} windows wine2winetgz " >&2 - echo "${0} windows autorun .* drive_c/P*" >&2 - echo "${0} windows autorun-list " >&2 - echo "${0} windows autorun-count " >&2 - echo "${0} windows stop" >&2 - exit 1 -esac -cleanAndExit $? -exit $? +_EPILOG = """\ +actions: + tricks [trick ...] + Apply winetricks to the prefix of the game, building the prefix + first if it has none yet. Everything after the game goes to + winetricks as it stands, so its own options work too: -q to + install without asking anything, --force to install again what + is already there. See https://github.com/Winetricks/winetricks + for the tricks there are. + createprefix Build the prefix of the game without playing it, and print where + it is. The game keeps a prefix of its own under + /userdata/system/wine-bottles///, so a .wine rom, + which is a prefix already, has nothing to build. + wine2squashfs Squash a .wine prefix into a read-only .wsquashfs beside + it, which the launcher mounts and writes to through an overlay. + Smaller and quicker to load, at the cost of not being editable. + wine2winetgz Compress a .wine prefix into a .wtgz in the roms directory + of the system, which is unpacked into a prefix of its own the + first time it is played. + autorun [game] [mask] + Write the autorun.cmd naming the executable to play, which is + what a prefix or a game directory needs to be playable. The + executables found are offered to choose from when there are + several, and an existing autorun.cmd is kept as autorun.cmd.bak. + autorun-list [game] [mask] + Print the executables autorun would offer, one per line, without + writing anything. + autorun-count [game] [mask] + Print how many there are. Exits 2 when there is none. + stop Close whatever runs in a wine prefix, each through the wineserver + that started it, which is what the exit hotkey does. Takes no + game. + +the game: + A rom of the system: a .wine prefix, or a .pc, .exe, .wtgz or .wsquashfs that runs in + a prefix of its own. Left out, it is the current directory, which is how the wine + toolbox calls this from inside a prefix. The autorun actions take any directory. + +the mask: + A glob narrowing where the autorun actions look, relative to the game, such as + 'drive_c/P*' to search the Program Files directories an installer writes to. Left out, + the whole directory is searched. + +the system: + Which system the game belongs to, and so which of its options apply: the runner it is + played with and whether its prefix is 32bit. Left out, it is windows. + +--debug: + The tool's own option, and so it has to come before the game: what follows the game + belongs to the action. + +exit status: + 0 done, 1 something went wrong, 2 nothing was found or the choice was abandoned. + +examples: + batocera-wine windows tricks "Age of Empires.wine" directplay + batocera-wine windows tricks "Age of Empires.wine" -q vcrun2022 dxvk + batocera-wine windows createprefix "Age of Empires.pc" + batocera-wine windows wine2squashfs "Age of Empires.wine" + batocera-wine windows autorun . "drive_c/P*" + batocera-wine --debug windows autorun-list "Age of Empires.wine" + batocera-wine stop +""" + + +def main(argv: Sequence[str] | None = None, /) -> int: + parser = argparse.ArgumentParser( + prog='batocera-wine', + description=__doc__, + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + 'system', help='the system the game belongs to, such as windows (default: windows)' + ) + parser.add_argument('action', choices=sorted(_ACTIONS), help='what to do, see below') + parser.add_argument( + 'game', nargs='?', help='the game the action works on, defaulting to the current directory' + ) + # everything after the game is handed over as it stands, so that a trick can be + # given the options winetricks takes, such as -q to install it unattended + parser.add_argument( + 'arguments', + nargs=argparse.REMAINDER, + help='what the action takes after the game: the tricks to apply, or the mask to search', + ) + parser.add_argument('--debug', action='store_true', help='log what is being done') + + arguments = list(sys.argv[1:] if argv is None else argv) + + # the system is the first argument, as batocera-wine has always taken it, but an + # action that doesn't work on a game doesn't need one: name the system it left out, + # past whatever options were given before it + first = next((index for index, value in enumerate(arguments) if not value.startswith('-')), None) + + if first is not None and arguments[first] in _ACTIONS: + arguments.insert(first, 'windows') + + args = parser.parse_args(arguments) + + logging.basicConfig( + stream=sys.stderr, + level=logging.DEBUG if args.debug else logging.INFO, + format='%(levelname)s: %(message)s', + ) + + try: + return _ACTIONS[args.action](args) + except BatoceraException as e: + _logger.error('%s', e) + return 1 + except subprocess.CalledProcessError as e: + _logger.error('%s failed with %s', e.cmd[0], e.returncode) + return e.returncode + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/package/batocera/wine/batocera-wine/batocera-wine.mk b/package/batocera/wine/batocera-wine/batocera-wine.mk index 4a109398703..a126ebadf2b 100644 --- a/package/batocera/wine/batocera-wine/batocera-wine.mk +++ b/package/batocera/wine/batocera-wine/batocera-wine.mk @@ -8,6 +8,9 @@ BATOCERA_WINE_VERSION = 1.5 BATOCERA_WINE_LICENSE = GPL BATOCERA_WINE_SOURCE= +# batocera-wine is a python tool built on the wine utils of the configgen +BATOCERA_WINE_DEPENDENCIES = batocera-configgen + define BATOCERA_WINE_INSTALL_TARGET_CMDS mkdir -p $(TARGET_DIR)/usr/bin mkdir -p $(TARGET_DIR)/etc/X11/xorg.conf.d