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:
Abdessamad Derraz committed 2026-11-09 19:29:10 +01:00
1 parent 39778a7359
commit 62f9ea6a76
11 files changed
+551 -16

No files matched your search

+17
View File
@@ -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"
+25
View File
@@ -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"
+16
View File
@@ -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
View File
@@ -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))
+94 -3
View File
@@ -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]
+62 -3
View File
@@ -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]
+24 -2
View File
@@ -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
+45
View File
@@ -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,
+217
View File
@@ -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()
+6 -1
View File
@@ -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)}
+5 -1
View File
@@ -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):