mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
feat: read the folders launchers hand switch, ps4
Batocera's 002-adjust-paths.patch rewrites Citron's KeysDir to /userdata/bios/switch: patched_paths reads it from the patch, and KNULLI from its batocera submodule at the pinned commit. Batocera and KNULLI start shadPS4 from /userdata/system/configs/shadps4 (os.chdir), KNULLI sets suyu's XDG_DATA_HOME to /userdata/saves/switch, and RetroBat creates the portable user folder beside citron.exe and shadPS4.exe. Each platform file differs from its scraper's full output by these records only. A profile names a folder a handle rewrites as "<handle> = <subfolder>", and a record setting a variable or a working directory a declared folder is spelled from binds the whole layout below it.
This commit is contained in:
1 parent
39778a7359
commit
62f9ea6a76
11 files changed
+551
-16
No files matched your search
@@ -6581,3 +6581,20 @@ launchers:
|
||||
fails_when_missing: false
|
||||
depends_on_option: neocd_bios
|
||||
source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/libretro/libretroOptions.py:2040-2041"
|
||||
- emulator: shadps4
|
||||
build: standalone
|
||||
kind: passes_path
|
||||
names: []
|
||||
search_dir: /userdata/system/configs/shadps4
|
||||
passes_to: working directory of shadps4Generator.py
|
||||
fails_when_missing: false
|
||||
source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/shadps4/shadps4Generator.py:46, 228"
|
||||
- emulator: citron
|
||||
build: standalone
|
||||
kind: passes_path
|
||||
names: []
|
||||
search_dir: /userdata/bios/switch
|
||||
passes_to: "CitronPath::KeysDir in src/common/fs/path_util.cpp returns it, in place of citron_path\
|
||||
\ / KEYS_DIR"
|
||||
fails_when_missing: false
|
||||
source_ref: "package/batocera/emulators/citron/002-adjust-paths.patch:9-10"
|
||||
@@ -5685,6 +5685,14 @@ launchers:
|
||||
passes_to: environment FLYCAST_BIOS_PATH
|
||||
fails_when_missing: false
|
||||
source_ref: "package/system/knulli-configgen/configgen/configgen/generators/flycast/flycastGenerator.py:239"
|
||||
- emulator: shadps4
|
||||
build: standalone
|
||||
kind: passes_path
|
||||
names: []
|
||||
search_dir: /userdata/system/configs/shadps4
|
||||
passes_to: working directory of shadps4Generator.py
|
||||
fails_when_missing: false
|
||||
source_ref: "package/system/knulli-configgen/configgen/configgen/generators/shadps4/shadps4Generator.py:33, 164"
|
||||
- emulator: lexaloffle
|
||||
build: standalone
|
||||
kind: fixed_name
|
||||
@@ -6135,3 +6143,20 @@ launchers:
|
||||
depends_on_option: not system.name == 'ti99'
|
||||
source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:431-432,\
|
||||
\ 435"
|
||||
- emulator: suyu
|
||||
build: standalone
|
||||
kind: passes_path
|
||||
names: []
|
||||
search_dir: /userdata/saves/switch
|
||||
passes_to: environment XDG_DATA_HOME
|
||||
fails_when_missing: false
|
||||
source_ref: "package/system/knulli-configgen/configgen/configgen/generators/suyu/suyuGenerator.py:41"
|
||||
- emulator: citron
|
||||
build: standalone
|
||||
kind: passes_path
|
||||
names: []
|
||||
search_dir: /userdata/bios/switch
|
||||
passes_to: "CitronPath::KeysDir in src/common/fs/path_util.cpp returns it, in place of citron_path\
|
||||
\ / KEYS_DIR"
|
||||
fails_when_missing: false
|
||||
source_ref: "batocera/package/batocera/emulators/citron/002-adjust-paths.patch:9-10"
|
||||
@@ -4966,6 +4966,22 @@ launchers:
|
||||
passes_to: exec
|
||||
fails_when_missing: returns no process when the binary is absent
|
||||
source_ref: "emulatorLauncher/Generators/Pico8.Generator.cs:14-18, 56-57"
|
||||
- emulator: citron
|
||||
build: standalone
|
||||
kind: passes_path
|
||||
names: []
|
||||
search_dir: "<AppConfig.GetFullPath(\"citron\")>/user"
|
||||
passes_to: PORTABLE_DIR, the user folder beside citron.exe
|
||||
fails_when_missing: false
|
||||
source_ref: "emulatorLauncher/Generators/Citron.Generator.cs:25, 29-30"
|
||||
- emulator: shadps4
|
||||
build: standalone
|
||||
kind: passes_path
|
||||
names: []
|
||||
search_dir: "<AppConfig.GetFullPath(\"shadps4\")>/user"
|
||||
passes_to: PORTABLE_DIR, the user folder beside shadPS4.exe
|
||||
fails_when_missing: false
|
||||
source_ref: "emulatorLauncher/Generators/ShadPS4.Generator.cs:29, 158-160"
|
||||
- emulator: bizhawk
|
||||
build: standalone
|
||||
kind: names_searched
|
||||
|
||||
+40
-6
@@ -431,13 +431,19 @@ def handed_directories(config: dict) -> list[HandedDirectory]:
|
||||
directory=directory,
|
||||
handles=handles,
|
||||
source_ref=str(record.get("source_ref", "")),
|
||||
roms_place=None if folder is not None else _roms_place(
|
||||
config, folder_in_roms(raw, config)
|
||||
roms_place=None if folder is not None else (
|
||||
_roms_place(config, folder_in_roms(raw, config))
|
||||
or _emulators_place(folder_in_emulators(raw, config))
|
||||
),
|
||||
))
|
||||
return found
|
||||
|
||||
|
||||
def _emulators_place(path: str | None) -> Place | None:
|
||||
"""A folder an emulator key names, as a place of the emulators tree."""
|
||||
return None if path is None else (EMULATORS_TREE, path)
|
||||
|
||||
|
||||
def _roms_place(config: dict, path: str | None) -> Place | None:
|
||||
"""A path below the roms root as a place: in the install tree when the
|
||||
platform installs above its BIOS folder (AmberELEC), else the roms tree."""
|
||||
@@ -480,9 +486,11 @@ def folder_in_emulators(directory: str, config: dict) -> str | None:
|
||||
spelling = str((config.get("emulators_directory") or {}).get("launcher_spelling") or "")
|
||||
if "{key}" not in spelling:
|
||||
return None
|
||||
pattern = re.escape(spelling).replace(re.escape("{key}"), r"([\w.-]+)")
|
||||
# The key, then any folder below it: Citron's generator creates
|
||||
# <folder>/user, its portable data folder.
|
||||
pattern = re.escape(spelling).replace(re.escape("{key}"), r"([\w.-]+)") + r"((?:/[\w.-]+)*)"
|
||||
found = re.fullmatch(pattern, directory.strip())
|
||||
return found.group(1) if found else None
|
||||
return found.group(1) + found.group(2) if found else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -566,8 +574,18 @@ def standalone_bindings(
|
||||
A subfolder two records give two places is left out.
|
||||
"""
|
||||
names = set(names)
|
||||
wanted = {normalize_handle(d) for d in declared}
|
||||
if not wanted:
|
||||
# "<handle> = <subfolder>" names the code's handle for one folder of the
|
||||
# layout: Citron's CitronPath::KeysDir is its keys/ folder, and a patch
|
||||
# that rewrites the handle moves that folder alone.
|
||||
named: dict[str, str] = {}
|
||||
wanted: set[str] = set()
|
||||
for raw in declared:
|
||||
handle, sep, sub = str(raw).partition(" = ")
|
||||
if sep:
|
||||
named[normalize_handle(handle)] = sanitize_pack_path(sub.strip())
|
||||
else:
|
||||
wanted.add(normalize_handle(handle))
|
||||
if not wanted and not named:
|
||||
return {}
|
||||
records = [h for h in handed if h.emulator in names]
|
||||
|
||||
@@ -583,7 +601,23 @@ def standalone_bindings(
|
||||
subs = {
|
||||
sub for handle in record.handles for directory in wanted
|
||||
if (sub := below(handle, directory)) is not None
|
||||
} | {named[handle] for handle in record.handles if handle in named}
|
||||
# A record setting an environment variable the declared directory is
|
||||
# spelled from ($XDG_DATA_HOME/suyu) puts the whole layout below it.
|
||||
variables = {
|
||||
directory: rest for handle in record.handles for directory in wanted
|
||||
for spelled in (f"${handle}", handle)
|
||||
if (rest := below(directory, spelled)) is not None and handle != directory
|
||||
}
|
||||
for rest in variables.values():
|
||||
place = record.place()
|
||||
if place is None:
|
||||
found.setdefault("", set()).add((OUTSIDE_TREE, ""))
|
||||
else:
|
||||
root, folder = place
|
||||
found.setdefault("", set()).add(
|
||||
(root, "/".join(p for p in (folder, rest) if p))
|
||||
)
|
||||
for sub in subs:
|
||||
if record.folder is not None:
|
||||
found.setdefault(sub, set()).add((BIOS_TREE, record.folder))
|
||||
|
||||
@@ -15,6 +15,7 @@ launcher looks for nothing".
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -104,9 +105,10 @@ def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None:
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "Path"
|
||||
and len(node.args) == 1
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
):
|
||||
return str(node.args[0].value)
|
||||
if isinstance(node.args[0], ast.Constant):
|
||||
return str(node.args[0].value)
|
||||
return _path_value(node.args[0], constants)
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
|
||||
left = _path_value(node.left, constants)
|
||||
right = node.right.value if isinstance(node.right, ast.Constant) else None
|
||||
@@ -898,6 +900,44 @@ def environment_directories(cg: Configgen) -> list[Launcher]:
|
||||
return launchers
|
||||
|
||||
|
||||
# Generators that start the emulator from a folder they enter with os.chdir.
|
||||
WORKING_DIRECTORY_GENERATORS = ("shadps4.shadps4Generator",)
|
||||
|
||||
|
||||
def working_directories(cg: Configgen) -> list[Launcher]:
|
||||
"""os.chdir(<folder>) in the generators above, before the command runs.
|
||||
|
||||
shadPS4 reads its user folder below the working directory first
|
||||
(std::filesystem::current_path() / "user"): Batocera enters
|
||||
/userdata/system/configs/shadps4.
|
||||
"""
|
||||
launchers = []
|
||||
for module in WORKING_DIRECTORY_GENERATORS:
|
||||
src = cg.generator(module)
|
||||
calls = _calls(src.tree, attr="chdir")
|
||||
if len(calls) != 1 or len(calls[0].args) != 1:
|
||||
src.fail("one os.chdir(folder) call")
|
||||
call = calls[0]
|
||||
argument = call.args[0]
|
||||
constants = dict(cg.paths)
|
||||
spans = [(call.lineno, call.end_lineno or call.lineno)]
|
||||
if isinstance(argument, ast.Name):
|
||||
scope = _innermost_function(src, call)
|
||||
assigned = src.assignment(argument.id, scope)
|
||||
spans.append((assigned.lineno, assigned.end_lineno or assigned.lineno))
|
||||
folder = _path_value(assigned.value, constants)
|
||||
else:
|
||||
folder = _path_value(argument, constants)
|
||||
if folder is None:
|
||||
src.fail("os.chdir names no folder")
|
||||
launchers.append(Launcher(
|
||||
emulator=cg.emulator(module), build="standalone", kind="passes_path", names=[],
|
||||
search_dir=folder, passes_to=f"working directory of {src.path.rsplit('/', 1)[-1]}",
|
||||
source_ref=src.ref(spans),
|
||||
))
|
||||
return launchers
|
||||
|
||||
|
||||
# libretro core options whose value names the BIOS file the core opens.
|
||||
FILE_OPTIONS = (("_o2em_options", "o2em_bios"), ("_neocd_options", "neocd_bios"))
|
||||
|
||||
@@ -950,13 +990,64 @@ def core_file_options(cg: Configgen) -> list[Launcher]:
|
||||
return launchers
|
||||
|
||||
|
||||
# Batocera's patches to an emulator's own path code. Each rewrites one of
|
||||
# the folders the emulator derives (yuzu's family generates every user
|
||||
# folder through Generate<Name>Path) to a fixed folder of /userdata.
|
||||
PATH_PATCHES = ("package/batocera/emulators/citron/002-adjust-paths.patch",)
|
||||
_PATH_REMOVED = re.compile(r"^-\s*Generate\w+Path\((\w+Path::\w+),\s*(.+?)\);\s*$")
|
||||
_PATH_ADDED = re.compile(r'^\+\s*Generate\w+Path\((\w+Path::\w+),\s*"(/userdata/bios[^"]*)"\);\s*$')
|
||||
|
||||
|
||||
def patched_paths(read: Callable[[str], str], patches: tuple[str, ...] = PATH_PATCHES) -> list[Launcher]:
|
||||
"""The emulator folders a path patch points at the BIOS tree.
|
||||
|
||||
A patch line replacing `GenerateCitronPath(CitronPath::KeysDir,
|
||||
citron_path / KEYS_DIR)` with a literal /userdata/bios folder hands
|
||||
that folder to the emulator under the handle the code names it by.
|
||||
The emulator is the package the patch belongs to.
|
||||
"""
|
||||
launchers: list[Launcher] = []
|
||||
for path in patches:
|
||||
lines = read(path).splitlines()
|
||||
target = ""
|
||||
removed: dict[str, tuple[int, str]] = {}
|
||||
found = 0
|
||||
for number, line in enumerate(lines, 1):
|
||||
if line.startswith("+++ b/"):
|
||||
target = line[len("+++ b/"):]
|
||||
continue
|
||||
if (old := _PATH_REMOVED.match(line)):
|
||||
removed[old.group(1)] = (number, old.group(2))
|
||||
continue
|
||||
if not (new := _PATH_ADDED.match(line)):
|
||||
continue
|
||||
handle, folder = new.groups()
|
||||
if handle not in removed or not target:
|
||||
raise LauncherShapeError(f"{path}:{number}: {handle} added with no line it replaces")
|
||||
first, original = removed[handle]
|
||||
found += 1
|
||||
launchers.append(Launcher(
|
||||
emulator=path.split("/")[-2], build="standalone", kind="passes_path", names=[],
|
||||
search_dir=folder,
|
||||
passes_to=f"{handle} in {target} returns it, in place of {original}",
|
||||
fails_when_missing=False,
|
||||
source_ref=f"{path}:{first}-{number}",
|
||||
))
|
||||
if not found:
|
||||
raise LauncherShapeError(f"{path}: no path rewritten to /userdata/bios")
|
||||
return launchers
|
||||
|
||||
|
||||
EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = (
|
||||
duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus,
|
||||
x16emu, gsplus, lexaloffle, environment_directories, core_file_options,
|
||||
working_directories,
|
||||
)
|
||||
|
||||
|
||||
def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]:
|
||||
"""Every launcher record, read through `read(repo-relative path)`."""
|
||||
cg = Configgen(read)
|
||||
return [launcher.entry() for extractor in EXTRACTORS for launcher in extractor(cg)]
|
||||
launchers = [launcher for extractor in EXTRACTORS for launcher in extractor(cg)]
|
||||
launchers += patched_paths(read)
|
||||
return [launcher.entry() for launcher in launchers]
|
||||
@@ -30,6 +30,7 @@ import yaml
|
||||
|
||||
try:
|
||||
from .batocera_launchers import (
|
||||
PATH_PATCHES,
|
||||
Configgen,
|
||||
_path_value,
|
||||
_render,
|
||||
@@ -40,11 +41,14 @@ try:
|
||||
hatari,
|
||||
melonds,
|
||||
mupen64plus,
|
||||
patched_paths,
|
||||
x16emu,
|
||||
working_directories,
|
||||
xemu,
|
||||
)
|
||||
from .configgen_launchers import (
|
||||
Launcher,
|
||||
_calls,
|
||||
LauncherShapeError,
|
||||
Source,
|
||||
_assigned_name,
|
||||
@@ -52,6 +56,7 @@ try:
|
||||
from .shell_launchers import LauncherPatternMissing, Script, source_ref
|
||||
except ImportError:
|
||||
from batocera_launchers import ( # type: ignore[no-redef]
|
||||
PATH_PATCHES,
|
||||
Configgen,
|
||||
_path_value,
|
||||
_render,
|
||||
@@ -62,11 +67,14 @@ except ImportError:
|
||||
hatari,
|
||||
melonds,
|
||||
mupen64plus,
|
||||
patched_paths,
|
||||
x16emu,
|
||||
working_directories,
|
||||
xemu,
|
||||
)
|
||||
from configgen_launchers import ( # type: ignore[no-redef]
|
||||
Launcher,
|
||||
_calls,
|
||||
LauncherShapeError,
|
||||
Source,
|
||||
_assigned_name,
|
||||
@@ -86,7 +94,7 @@ ES_FEATURES = "package/emulationstation/knulli-es-system/es_features.yml"
|
||||
# Batocera's extractors that read KNULLI's generators unchanged.
|
||||
SHARED = (
|
||||
duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus, x16emu,
|
||||
environment_directories,
|
||||
environment_directories, working_directories,
|
||||
)
|
||||
|
||||
|
||||
@@ -1054,14 +1062,65 @@ EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]:
|
||||
"""Every launcher record, read through `read(repo-relative path)`."""
|
||||
# Generators whose command sets the emulator's XDG data folder, the parent
|
||||
# of every user folder a yuzu-family emulator derives.
|
||||
DATA_HOME_GENERATORS = ("suyu.suyuGenerator",)
|
||||
|
||||
|
||||
def data_homes(cg: Configgen) -> list[Launcher]:
|
||||
"""Command(..., env={"XDG_DATA_HOME": <path>}) in the generators above.
|
||||
|
||||
The path need not lie in /userdata/bios: KNULLI's suyu keeps its data
|
||||
in /userdata/saves/switch, which tells where its keys are not.
|
||||
"""
|
||||
launchers = []
|
||||
for module in DATA_HOME_GENERATORS:
|
||||
src = cg.generator(module)
|
||||
found = [
|
||||
(_path_value(item, cg.paths), key)
|
||||
for call in _calls(src.tree, attr="Command")
|
||||
for keyword in call.keywords
|
||||
if keyword.arg == "env" and isinstance(keyword.value, ast.Dict)
|
||||
for key, item in zip(keyword.value.keys, keyword.value.values)
|
||||
if isinstance(key, ast.Constant) and key.value == "XDG_DATA_HOME"
|
||||
]
|
||||
if not found or found[0][0] is None:
|
||||
src.fail("the command environment sets no XDG_DATA_HOME path")
|
||||
value, node = found[0]
|
||||
launchers.append(Launcher(
|
||||
emulator=cg.emulator(module), build="standalone", kind="passes_path", names=[],
|
||||
search_dir=value, passes_to="environment XDG_DATA_HOME",
|
||||
source_ref=src.ref([(node.lineno, node.end_lineno)]),
|
||||
))
|
||||
return launchers
|
||||
|
||||
|
||||
# KNULLI builds Batocera's emulator packages from this submodule, at the
|
||||
# batocera.linux commit it pins: Citron's path patch is there.
|
||||
BATOCERA_SUBMODULE = "batocera"
|
||||
|
||||
|
||||
def extract_launchers(
|
||||
read: Callable[[str], str], batocera: Callable[[str], str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Every launcher record, read through `read(repo-relative path)`.
|
||||
|
||||
`batocera` reads the batocera submodule at its pinned commit. The
|
||||
records it gives name the file by its path in KNULLI's repository.
|
||||
"""
|
||||
cg = configgen(read)
|
||||
launchers = [launcher for extractor in EXTRACTORS for launcher in extractor(cg)]
|
||||
launchers += pico8_installer(read, cg.bios)
|
||||
launchers += boot_disks(cg, read)
|
||||
for spec in GENERIC_SITES:
|
||||
launchers += bios_sites(cg, spec)
|
||||
launchers += data_homes(cg)
|
||||
if batocera is not None:
|
||||
prefix = f"{BATOCERA_SUBMODULE}/"
|
||||
launchers += patched_paths(
|
||||
lambda path: batocera(path.removeprefix(prefix)),
|
||||
tuple(prefix + patch for patch in PATH_PATCHES),
|
||||
)
|
||||
return [launcher.entry() for launcher in launchers]
|
||||
|
||||
|
||||
|
||||
@@ -45,12 +45,15 @@ from .batocera_scraper import (
|
||||
requirements_from_systems,
|
||||
)
|
||||
from .es_emulators import attach_system_cores
|
||||
from .knulli_launchers import extract_launchers
|
||||
from .knulli_launchers import BATOCERA_SUBMODULE, extract_launchers
|
||||
|
||||
PLATFORM_NAME = "knulli"
|
||||
|
||||
REPO = "knulli-cfw/knulli-linux"
|
||||
_RAW = "https://raw.githubusercontent.com/knulli-cfw/knulli-linux/{ref}/"
|
||||
# The batocera.linux revision KNULLI's submodule pins, and its files.
|
||||
_SUBMODULE = "https://api.github.com/repos/knulli-cfw/knulli-linux/contents/{path}?ref={ref}"
|
||||
_BATOCERA_RAW = "https://raw.githubusercontent.com/batocera-linux/batocera.linux/{ref}/"
|
||||
SOURCE_TEMPLATE = _RAW + "package/system/knulli-scripts/scripts/knulli-systems"
|
||||
CONFIGGEN_DEFAULTS_TEMPLATE = (
|
||||
_RAW + "package/system/knulli-configgen/configs/configgen-defaults.yml"
|
||||
@@ -83,6 +86,23 @@ class Scraper(BaseScraper):
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
|
||||
|
||||
def submodule_revision(self) -> str:
|
||||
"""The batocera.linux commit KNULLI's batocera submodule pins at the tag."""
|
||||
import json # noqa: PLC0415
|
||||
|
||||
from .base_scraper import github_headers # noqa: PLC0415
|
||||
|
||||
url = _SUBMODULE.format(path=BATOCERA_SUBMODULE, ref=self.tag)
|
||||
req = urllib.request.Request(url, headers=github_headers())
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
found = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
|
||||
if not isinstance(found, dict) or found.get("type") != "submodule" or not found.get("sha"):
|
||||
raise RuntimeError(f"{url}: {BATOCERA_SUBMODULE} is not a submodule")
|
||||
return str(found["sha"])
|
||||
|
||||
def validate_format(self, raw_data: str) -> bool:
|
||||
has_dict = re.search(r"^systems\s*=\s*\{", raw_data, re.MULTILINE) is not None
|
||||
return has_dict and "def checkBios" in raw_data and '"biosFiles"' in raw_data
|
||||
@@ -126,8 +146,10 @@ class Scraper(BaseScraper):
|
||||
# configgen hands a libretro core /userdata/saves/<system>, as
|
||||
# Batocera's does: the systems each core runs for are its save folders.
|
||||
result["libretro_systems"] = es_systems_libretro_systems(es_systems, dropped)
|
||||
revision = self.submodule_revision()
|
||||
result["launchers"] = extract_launchers(
|
||||
lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path)
|
||||
lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path),
|
||||
lambda path: self._fetch_text(_BATOCERA_RAW.format(ref=revision) + path),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ SOURCE_FILES = (
|
||||
f"{GENERATORS}/Bizhawk.CoreOptions.cs",
|
||||
f"{GENERATORS}/Bizhawk.Generator.cs",
|
||||
f"{GENERATORS}/Blastem.Generator.cs",
|
||||
f"{GENERATORS}/Citron.Generator.cs",
|
||||
f"{GENERATORS}/Demul.Generator.cs",
|
||||
f"{GENERATORS}/Desmume.Generator.cs",
|
||||
f"{GENERATORS}/Dolphin.Generator.cs",
|
||||
@@ -58,6 +59,7 @@ SOURCE_FILES = (
|
||||
f"{GENERATORS}/Rpcs3.Generator.cs",
|
||||
f"{GENERATORS}/SSF.Generator.cs",
|
||||
f"{GENERATORS}/ScummVm.Generator.cs",
|
||||
f"{GENERATORS}/ShadPS4.Generator.cs",
|
||||
f"{GENERATORS}/Snes9x.Generator.cs",
|
||||
f"{GENERATORS}/Tsugaru.Generator.cs",
|
||||
f"{GENERATORS}/Uae.Generator.cs",
|
||||
@@ -2980,6 +2982,48 @@ def xroar(sources: dict[str, Source]) -> list[Record]:
|
||||
return _covered(src, records)
|
||||
|
||||
|
||||
# Generators that create a portable user folder beside the emulator's binary,
|
||||
# and the emulator each one starts: the folder AppConfig.GetFullPath gives.
|
||||
PORTABLE_USERS = (
|
||||
("Citron.Generator.cs", "citron", "citron.exe"),
|
||||
("ShadPS4.Generator.cs", "shadps4", "shadPS4.exe"),
|
||||
)
|
||||
_EMULATOR_FOLDER = (
|
||||
r"GetEmulatorPathAndExe\(emulator, out string path, out string exe, \"[^\"]+\"\)"
|
||||
r"|string path = AppConfig\.GetFullPath\(emulator\);"
|
||||
)
|
||||
|
||||
|
||||
def portable_users(sources: dict[str, Source]) -> list[Record]:
|
||||
"""The portable user folder a generator creates beside the binary.
|
||||
|
||||
The emulator folder comes from AppConfig.GetFullPath(emulator), and the
|
||||
generator creates <folder>/user before launching: the emulator then
|
||||
keeps its data there (keys/, sys_modules/) in place of %APPDATA%.
|
||||
"""
|
||||
records = []
|
||||
for name, emulator, binary in PORTABLE_USERS:
|
||||
src = sources[f"{GENERATORS}/{name}"]
|
||||
found = src.find(_EMULATOR_FOLDER, f"{emulator} folder")
|
||||
user = src.find(r'string userFolder = Path\.Combine\(path, "(\w+)"\);', "user folder")
|
||||
create = src.find(
|
||||
r"Directory\.CreateDirectory\(userFolder\)", "user folder creation", user.end()
|
||||
)
|
||||
records.append(_record(
|
||||
emulator,
|
||||
"standalone",
|
||||
"passes_path",
|
||||
[],
|
||||
source_ref((src.path, [
|
||||
src.lines(found.start(), found.end()), src.lines(user.start(), create.end()),
|
||||
])),
|
||||
search_dir=f'<AppConfig.GetFullPath("{emulator}")>/{user.group(1)}',
|
||||
passes_to=f"PORTABLE_DIR, the user folder beside {binary}",
|
||||
fails_when_missing=False,
|
||||
))
|
||||
return records
|
||||
|
||||
|
||||
def pico8(sources: dict[str, Source]) -> list[Record]:
|
||||
"""The PICO-8 binary the generator runs, from an emulator folder.
|
||||
|
||||
@@ -3287,6 +3331,7 @@ EXTRACTORS: tuple[Callable[[dict[str, Source]], list[Record]], ...] = (
|
||||
pcsx2,
|
||||
pcsx2_libretro,
|
||||
pico8,
|
||||
portable_users,
|
||||
psx_cascades,
|
||||
psxmame,
|
||||
raine,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Folders a platform's launcher hands an emulator, read from its code.
|
||||
|
||||
Batocera patches Citron's path code to read its keys from /userdata/bios/
|
||||
switch and starts shadPS4 from /userdata/system/configs/shadps4, KNULLI sets
|
||||
suyu's XDG data folder, and RetroBat creates a portable user folder beside
|
||||
Citron and shadPS4. Each fixture is a cut-down copy of the lines at the
|
||||
revisions the platform files pin, and a binding reads the record the
|
||||
extractor writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from launchers import handed_directories, standalone_bindings # noqa: E402
|
||||
from scraper import knulli_launchers as kl # noqa: E402
|
||||
from scraper import retrobat_launchers as rl # noqa: E402
|
||||
from scraper.batocera_launchers import ( # noqa: E402
|
||||
GENERATORS,
|
||||
IMPORTER,
|
||||
PATHS_MODULE,
|
||||
Configgen,
|
||||
LauncherShapeError,
|
||||
patched_paths,
|
||||
working_directories,
|
||||
)
|
||||
|
||||
PATCH = "package/batocera/emulators/citron/002-adjust-paths.patch"
|
||||
PATCH_TEXT = textwrap.dedent("""\
|
||||
diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp
|
||||
index f92cbe0f7b..9e3dcd9c18 100644
|
||||
--- a/src/common/fs/path_util.cpp
|
||||
+++ b/src/common/fs/path_util.cpp
|
||||
@@ -121,12 +121,12 @@ public:
|
||||
GenerateCitronPath(CitronPath::ConfigDir, citron_path_config);
|
||||
GenerateCitronPath(CitronPath::DumpDir, citron_path / DUMP_DIR);
|
||||
- GenerateCitronPath(CitronPath::KeysDir, citron_path / KEYS_DIR);
|
||||
+ GenerateCitronPath(CitronPath::KeysDir, "/userdata/bios/switch");
|
||||
- GenerateCitronPath(CitronPath::LogDir, citron_path / LOG_DIR);
|
||||
+ GenerateCitronPath(CitronPath::LogDir, "/userdata/system/logs");
|
||||
""")
|
||||
|
||||
PATHS = """from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
USERDATA: Final = Path('/userdata')
|
||||
BIOS: Final = USERDATA / 'bios'
|
||||
SAVES: Final = USERDATA / 'saves'
|
||||
CONFIGS: Final = USERDATA / 'system' / 'configs'
|
||||
"""
|
||||
IMPORTER_TEXT = """_LEGACY_GENERATOR_MAP = {}
|
||||
_GENERATOR_MAP = {}
|
||||
"""
|
||||
SHADPS4 = f"{GENERATORS}/shadps4/shadps4Generator.py"
|
||||
SHADPS4_TEXT = textwrap.dedent("""\
|
||||
import os
|
||||
from ...batoceraPaths import CONFIGS
|
||||
|
||||
class ShadPS4Generator:
|
||||
def generate(self, system, rom):
|
||||
configPath = CONFIGS / "shadps4"
|
||||
userConfigPath = configPath / "user"
|
||||
os.chdir(configPath)
|
||||
return None
|
||||
""")
|
||||
|
||||
|
||||
def records(entries: list) -> list[dict]:
|
||||
return [entry.entry() if hasattr(entry, "entry") else entry for entry in entries]
|
||||
|
||||
|
||||
class APatchRewritingAFolder(unittest.TestCase):
|
||||
def test_the_bios_folder_a_patch_gives_is_a_record(self):
|
||||
(record,) = records(patched_paths({PATCH: PATCH_TEXT}.__getitem__))
|
||||
self.assertEqual(
|
||||
(record["emulator"], record["kind"], record["search_dir"], record["source_ref"]),
|
||||
("citron", "passes_path", "/userdata/bios/switch", f"{PATCH}:8-9"),
|
||||
)
|
||||
self.assertIn("CitronPath::KeysDir", record["passes_to"])
|
||||
self.assertIn("citron_path / KEYS_DIR", record["passes_to"])
|
||||
|
||||
def test_a_patch_that_no_longer_rewrites_a_bios_folder_stops_the_scrape(self):
|
||||
text = PATCH_TEXT.replace('"/userdata/bios/switch"', '"/userdata/saves/switch"')
|
||||
with self.assertRaises(LauncherShapeError):
|
||||
patched_paths({PATCH: text}.__getitem__)
|
||||
|
||||
def test_the_named_handle_binds_its_folder_alone(self):
|
||||
config = {"base_destination": "bios", "standalone_cores": ["citron"], "launchers":
|
||||
records(patched_paths({PATCH: PATCH_TEXT}.__getitem__))}
|
||||
found = standalone_bindings(
|
||||
{"citron"}, ["$XDG_DATA_HOME/citron", "CitronPath::KeysDir = keys"],
|
||||
handed_directories(config),
|
||||
)
|
||||
self.assertEqual(found, {"keys": ("", "switch")})
|
||||
|
||||
|
||||
class AFolderTheGeneratorEnters(unittest.TestCase):
|
||||
def tree(self, text: str = SHADPS4_TEXT) -> Configgen:
|
||||
files = {PATHS_MODULE: PATHS, IMPORTER: IMPORTER_TEXT, SHADPS4: text}
|
||||
return Configgen(files.__getitem__)
|
||||
|
||||
def test_the_working_directory_is_a_record(self):
|
||||
(record,) = records(working_directories(self.tree()))
|
||||
self.assertEqual(
|
||||
(record["emulator"], record["search_dir"], record["passes_to"], record["source_ref"]),
|
||||
("shadps4", "/userdata/system/configs/shadps4",
|
||||
"working directory of shadps4Generator.py", f"{SHADPS4}:6, 8"),
|
||||
)
|
||||
|
||||
def test_a_generator_that_no_longer_enters_a_folder_stops_the_scrape(self):
|
||||
with self.assertRaises(LauncherShapeError):
|
||||
working_directories(self.tree(SHADPS4_TEXT.replace("os.chdir(configPath)", "pass")))
|
||||
|
||||
def test_the_user_folder_below_it_is_outside_the_bios_tree(self):
|
||||
config = {"base_destination": "bios", "standalone_cores": ["shadps4"],
|
||||
"launchers": records(working_directories(self.tree()))}
|
||||
found = standalone_bindings(
|
||||
{"shadps4"}, ["getcwd/user", "$XDG_DATA_HOME/shadPS4"], handed_directories(config)
|
||||
)
|
||||
self.assertEqual(found, {"": ("outside", "")})
|
||||
|
||||
|
||||
class ADataFolderTheEnvironmentSets(unittest.TestCase):
|
||||
SUYU = f"{kl.GENERATORS}/suyu/suyuGenerator.py"
|
||||
TEXT = textwrap.dedent("""\
|
||||
from ...batoceraPaths import CACHE, CONFIGS, SAVES
|
||||
|
||||
class SuyuGenerator:
|
||||
def generate(self, system, rom):
|
||||
commandArray = ["/usr/bin/suyu", "-f", "-g", rom]
|
||||
return Command.Command(array=commandArray, env={
|
||||
"XDG_CONFIG_HOME": CONFIGS,
|
||||
"XDG_DATA_HOME": SAVES / "switch",
|
||||
})
|
||||
""")
|
||||
|
||||
def test_the_data_folder_is_a_record_and_binds_outside(self):
|
||||
files = {f"{kl.CONFIGGEN}/batoceraPaths.py": PATHS,
|
||||
f"{kl.GENERATORS}/importer.py": IMPORTER_TEXT, self.SUYU: self.TEXT}
|
||||
(record,) = records(kl.data_homes(kl.configgen(files.__getitem__)))
|
||||
self.assertEqual(
|
||||
(record["emulator"], record["search_dir"], record["passes_to"], record["source_ref"]),
|
||||
("suyu", "/userdata/saves/switch", "environment XDG_DATA_HOME", f"{self.SUYU}:8"),
|
||||
)
|
||||
config = {"base_destination": "bios", "standalone_cores": ["suyu"], "launchers": [record]}
|
||||
found = standalone_bindings(
|
||||
{"suyu"}, ["$XDG_DATA_HOME/suyu", "SuyuPath::KeysDir = keys"],
|
||||
handed_directories(config),
|
||||
)
|
||||
self.assertEqual(found, {"": ("outside", "")})
|
||||
|
||||
|
||||
class APortableUserFolder(unittest.TestCase):
|
||||
CITRON = """
|
||||
public override System.Diagnostics.ProcessStartInfo Generate(string system, string emulator, string core, string rom, string playersControllers, ScreenResolution resolution)
|
||||
{
|
||||
if (!GetEmulatorPathAndExe(emulator, out string path, out string exe, "citron.exe"))
|
||||
return null;
|
||||
|
||||
// Ensure user folder exists
|
||||
string userFolder = Path.Combine(path, "user");
|
||||
if (!Directory.Exists(userFolder)) try { Directory.CreateDirectory(userFolder); }
|
||||
catch { }
|
||||
}
|
||||
"""
|
||||
SHADPS4 = """
|
||||
public override System.Diagnostics.ProcessStartInfo Generate(string system, string emulator, string core, string rom, string playersControllers, ScreenResolution resolution)
|
||||
{
|
||||
string path = AppConfig.GetFullPath(emulator);
|
||||
}
|
||||
|
||||
private void SetupConfigurationJSON(string path, string rom)
|
||||
{
|
||||
string userFolder = Path.Combine(path, "user");
|
||||
if (!Directory.Exists(userFolder))
|
||||
try { Directory.CreateDirectory(userFolder); } catch { }
|
||||
}
|
||||
"""
|
||||
|
||||
def sources(self) -> dict[str, rl.Source]:
|
||||
return {
|
||||
f"{rl.GENERATORS}/Citron.Generator.cs": rl.Source(
|
||||
"c", textwrap.dedent(self.CITRON).lstrip("\n")),
|
||||
f"{rl.GENERATORS}/ShadPS4.Generator.cs": rl.Source(
|
||||
"s", textwrap.dedent(self.SHADPS4).lstrip("\n")),
|
||||
}
|
||||
|
||||
def test_each_generator_gives_its_user_folder(self):
|
||||
found = rl.portable_users(self.sources())
|
||||
self.assertEqual(
|
||||
[(r["emulator"], r["search_dir"], r["source_ref"]) for r in found],
|
||||
[("citron", '<AppConfig.GetFullPath("citron")>/user', "c:3, 7-8"),
|
||||
("shadps4", '<AppConfig.GetFullPath("shadps4")>/user', "s:3, 8-10")],
|
||||
)
|
||||
|
||||
def test_the_user_folder_binds_below_the_emulator_folder(self):
|
||||
config = {
|
||||
"base_destination": "bios", "standalone_cores": ["citron"],
|
||||
"emulators_directory": {"launcher_spelling": '<AppConfig.GetFullPath("{key}")>'},
|
||||
"launchers": rl.portable_users(self.sources())[:1],
|
||||
}
|
||||
found = standalone_bindings(
|
||||
{"citron"}, ["$XDG_DATA_HOME/citron", "PORTABLE_DIR"], handed_directories(config)
|
||||
)
|
||||
self.assertEqual(found, {"": ("emulators", "citron/user")})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -551,8 +551,13 @@ class PlatformRecords(unittest.TestCase):
|
||||
self.assertEqual(sorted(named - cores), [])
|
||||
|
||||
def test_every_reference_is_in_configgen(self):
|
||||
# Or in a patch Batocera applies to an emulator's own path code.
|
||||
from scraper.batocera_launchers import PATH_PATCHES
|
||||
|
||||
for record in self.config["launchers"]:
|
||||
self.assertTrue(record["source_ref"].startswith(f"{CONFIGGEN}/"), record)
|
||||
self.assertTrue(
|
||||
record["source_ref"].startswith((f"{CONFIGGEN}/", *PATH_PATCHES)), record
|
||||
)
|
||||
|
||||
def test_the_schema_describes_what_the_scraper_writes(self):
|
||||
fields = {f.name for f in dataclasses.fields(Launcher)}
|
||||
|
||||
@@ -623,8 +623,12 @@ class PlatformFile(unittest.TestCase):
|
||||
|
||||
def test_every_reference_is_in_knulli_code(self):
|
||||
for record in self.config["launchers"]:
|
||||
# Or in Batocera's patches, read from KNULLI's batocera submodule.
|
||||
self.assertTrue(
|
||||
record["source_ref"].startswith((f"{kl.CONFIGGEN}/", kl.PICO8_INSTALLER)), record
|
||||
record["source_ref"].startswith((
|
||||
f"{kl.CONFIGGEN}/", kl.PICO8_INSTALLER,
|
||||
*(f"{kl.BATOCERA_SUBMODULE}/{patch}" for patch in kl.PATH_PATCHES),
|
||||
)), record
|
||||
)
|
||||
|
||||
def test_every_emulator_is_one_knulli_names(self):
|
||||
|
||||
Reference in new issue
Block a user