From f0289e3624eb81fc2e44c7422296c7d4752d8ab5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:59:09 +0100 Subject: [PATCH] feat: record pcsx2's relative bios folder configureINI writes [Folders] Bios = ../../../bios/ps2 into inis/PCSX2.ini, which PCSX2 and AetherSX2 resolve against the config folder configgen hands it. relative_folders reads the call, the ini path and the value, and records the resolved /userdata/bios/ps2 as a passes_path for pcsx2 and aethersx2. --- platforms/knulli.yml | 18 +++++++ scripts/scraper/knulli_launchers.py | 77 ++++++++++++++++++++++++++++- tests/test_launchers_knulli.py | 52 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/platforms/knulli.yml b/platforms/knulli.yml index 5d443be22..4e25c2144 100644 --- a/platforms/knulli.yml +++ b/platforms/knulli.yml @@ -5732,6 +5732,24 @@ launchers: depends_on_option: neocd_bios source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroOptions.py:2558-2559,\ \ 2566-2569" +- emulator: pcsx2 + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/ps2 + passes_to: "inis/PCSX2.ini [Folders] Bios = ../../../bios/ps2, relative to /userdata/system/configs/PCSX2" + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/pcsx2/pcsx2Generator.py:92,\ + \ 176, 213" +- emulator: aethersx2 + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/ps2 + passes_to: "inis/PCSX2.ini [Folders] Bios = ../../../bios/ps2, relative to /userdata/system/.config/aethersx2" + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/aethersx2/aethersx2Generator.py:71,\ + \ 182, 219" - emulator: lexaloffle build: standalone kind: moves diff --git a/scripts/scraper/knulli_launchers.py b/scripts/scraper/knulli_launchers.py index 219174230..a01cd8a8c 100644 --- a/scripts/scraper/knulli_launchers.py +++ b/scripts/scraper/knulli_launchers.py @@ -20,6 +20,7 @@ LauncherShapeError, as every other extractor does. from __future__ import annotations import ast +import posixpath import re from collections.abc import Callable, Iterator from dataclasses import dataclass, field @@ -834,8 +835,82 @@ def bios_sites(cg: Configgen, spec: GenericSites) -> list[Launcher]: return launchers +# Generators that write a [Folders] path relative to the config folder they +# are given. PCSX2 and AetherSX2 resolve those paths against their data root, +# the folder holding inis/, which configureINI receives as config_directory. +RELATIVE_FOLDERS = ( + ("pcsx2/pcsx2Generator.py", "configureINI"), + ("aethersx2/aethersx2Generator.py", "configureINI"), +) + + +def relative_folders( + cg: Configgen, specs: tuple[tuple[str, str], ...] = RELATIVE_FOLDERS +) -> list[Launcher]: + """parser.set(section, key, "../") resolved against config_directory.""" + launchers = [] + for path, function_name in specs: + src = cg.source(f"{cg.generators}/{path}") + reader = _Reader(cg, src) + function = src.function(function_name) + if not function.args.args: + src.fail(f"{function_name} takes no config folder") + folder = function.args.args[0].arg + ini = [ + node for node in ast.walk(function) + if isinstance(node, ast.Assign) and isinstance(node.value, ast.BinOp) + and ast.unparse(node.value).startswith(f"{folder} /") + and ast.unparse(node.value).endswith(".ini'") + ] + if len(ini) != 1: + src.fail(f"{function_name} builds no one ini path under {folder}") + parts = sorted( + (n for n in ast.walk(ini[0].value) + if isinstance(n, ast.Constant) and isinstance(n.value, str)), + key=lambda n: (n.lineno, n.col_offset), + ) + ini_name = posixpath.join(*(str(n.value) for n in parts)) + calls = [ + node for node in ast.walk(src.tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == function_name and node.args + ] + if len(calls) != 1: + src.fail(f"expected one call of {function_name}, found {len(calls)}") + base = reader.resolve(calls[0].args[0]) + if base is None or not base.startswith("/"): + src.fail(f"{function_name} is given a folder that is not a path") + found = 0 + for call in ast.walk(function): + if not ( + isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) + and call.func.attr == "set" and len(call.args) == 3 + and all(isinstance(a, ast.Constant) for a in call.args) + ): + continue + section, key, value = (str(a.value) for a in call.args) # type: ignore[attr-defined] + if not value.startswith("../"): + continue + resolved = posixpath.normpath(posixpath.join(base, value)) + if not reader.under_bios(resolved): + continue + found += 1 + launchers.append(Launcher( + emulator=cg.emulator(path.removesuffix(".py").replace("/", ".")), + build="standalone", kind="passes_path", names=[], search_dir=resolved, + passes_to=f"{ini_name} [{section}] {key} = {value}, relative to {base}", + fails_when_missing=False, + source_ref=src.ref([ + src.span(reader.statement_of(calls[0])), src.span(ini[0]), _lines(call), + ]), + )) + if not found: + src.fail(f"{function_name} writes no relative folder under {cg.bios}") + return launchers + + EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = ( - *SHARED, lexaloffle, core_file_options, + *SHARED, lexaloffle, core_file_options, relative_folders, ) diff --git a/tests/test_launchers_knulli.py b/tests/test_launchers_knulli.py index ca0b6e7da..9e235fbec 100644 --- a/tests/test_launchers_knulli.py +++ b/tests/test_launchers_knulli.py @@ -59,6 +59,7 @@ IMPORTER = text( 'openmsx': ('openmsx.openmsxGenerator', 'OpenmsxGenerator'), 'hatari': ('hatari.hatariGenerator', 'HatariGenerator'), 'melonds': ('melonds.melondsGenerator', 'MelonDSGenerator'), + 'pcsx2': ('pcsx2.pcsx2Generator', 'Pcsx2Generator'), } """ ) @@ -305,6 +306,29 @@ MELONDS = text( ) +# pcsx2Generator.py: the default patches copied into BIOS when absent, and +# the BIOS folder written relative to the config folder. +PCSX2 = text( + """ + from ...batoceraPaths import BIOS, CONFIGS, DATAINIT_DIR + + _PCSX2_CONFIG: Final = CONFIGS / "PCSX2" + _PCSX2_BIOS: Final = BIOS / "ps2" + + class Pcsx2Generator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + pcsx2Patches = _PCSX2_BIOS / "patches.zip" + configureINI(_PCSX2_CONFIG, _PCSX2_BIOS, system) + if not pcsx2Patches.exists(): + shutil.copy(DATAINIT_DIR / "bios" / "ps2" / "patches.zip", pcsx2Patches) + + def configureINI(config_directory, bios_directory, system): + configFileName = config_directory / 'inis' / "PCSX2.ini" + pcsx2INIConfig.set("Folders", "Bios", "../../../bios/ps2") + pcsx2INIConfig.set("Folders", "Logs", "../../logs") + """ +) + def tree(**overrides: str) -> dict[str, str]: files = { f"{kl.CONFIGGEN}/batoceraPaths.py": PATHS, @@ -322,6 +346,7 @@ def tree(**overrides: str) -> dict[str, str]: f"{G}/libretro/libretroMAMEConfig.py": MAME_CONFIG, f"{G}/hatari/hatariGenerator.py": HATARI, f"{G}/melonds/melondsGenerator.py": MELONDS, + f"{G}/pcsx2/pcsx2Generator.py": PCSX2, } files.update(overrides) return files @@ -498,6 +523,33 @@ class BiosSites(unittest.TestCase): kl.bios_sites(kl.configgen(files.__getitem__), spec) +class CopiesAndFolders(unittest.TestCase): + def test_a_copy_into_bios_reads_from_the_folder_it_comes_from(self): + (record,) = sites("pcsx2/pcsx2Generator.py", "fixed_name") + self.assertEqual(record["kind"], "copies") + self.assertEqual(record["names"], ["patches.zip"]) + self.assertEqual(record["search_dir"], "/usr/share/knulli/datainit/bios/ps2") + self.assertEqual(record["passes_to"], "/userdata/bios/ps2/patches.zip") + self.assertEqual(record["depends_on_option"], "not pcsx2Patches.exists()") + + def test_a_relative_folder_resolved_against_the_config_folder(self): + (record,) = kl.relative_folders(configgen(), kl.RELATIVE_FOLDERS[:1]) + self.assertEqual(record.kind, "passes_path") + self.assertEqual(record.search_dir, "/userdata/bios/ps2") + self.assertEqual( + record.passes_to, + "inis/PCSX2.ini [Folders] Bios = ../../../bios/ps2, relative to /userdata/system/configs/PCSX2", + ) + self.assertEqual(record.source_ref, f"{G}/pcsx2/pcsx2Generator.py:9, 14-15") + + def test_a_folder_written_outside_bios_raises(self): + broken = PCSX2.replace("../../../bios/ps2", "../../../saves/ps2") + with self.assertRaises(LauncherShapeError): + kl.relative_folders( + configgen(**{f"{G}/pcsx2/pcsx2Generator.py": broken}), kl.RELATIVE_FOLDERS[:1] + ) + + class PlatformFile(unittest.TestCase): @classmethod def setUpClass(cls) -> None: