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

+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,