refactor: one meaning for copy records

A copies record names its files under search_dir, the folder the code
copies from, and passes_to is the target, as the schema and RetroPie,
RetroDECK and AmberELEC already write it. KNULLI listed patches.zip
under the BIOS folder it is copied into, and KNULLI and RetroBat wrote
the target as prose with the condition. The condition of a copy now
goes in depends_on_option. Only launcher records change in knulli.yml
and retrobat.yml.
This commit is contained in:
Abdessamad Derraz committed 2026-11-09 17:58:37 +01:00
1 parent ffad06487c
commit cedeee2254
8 files changed
+105 -64

No files matched your search

+10 -7
View File
@@ -5793,29 +5793,32 @@ launchers:
- switch/prod.keys
case_insensitive: false
search_dir: /userdata/bios
passes_to: copied to /userdata/system/configs/Ryujinx/system/prod.keys
passes_to: /userdata/system/configs/Ryujinx/system/prod.keys
fails_when_missing: false
depends_on_option: "ryujinxKeys.exists()"
source_ref: "package/system/knulli-configgen/configgen/configgen/generators/ryujinx/ryujinxGenerator.py:107-109"
- emulator: pcsx2
build: standalone
kind: copies
names:
- ps2/patches.zip
- patches.zip
case_insensitive: false
search_dir: /userdata/bios
passes_to: copied from /usr/share/knulli/datainit/bios/ps2/patches.zip
search_dir: /usr/share/knulli/datainit/bios/ps2
passes_to: /userdata/bios/ps2/patches.zip
fails_when_missing: false
depends_on_option: "not pcsx2Patches.exists()"
source_ref: "package/system/knulli-configgen/configgen/configgen/generators/pcsx2/pcsx2Generator.py:78,\
\ 121-122"
- emulator: aethersx2
build: standalone
kind: copies
names:
- ps2/patches.zip
- patches.zip
case_insensitive: false
search_dir: /userdata/bios
passes_to: copied from /usr/share/knulli/datainit/bios/ps2/patches.zip
search_dir: /usr/share/knulli/datainit/bios/ps2
passes_to: /userdata/bios/ps2/patches.zip
fails_when_missing: false
depends_on_option: "not pcsx2Patches.exists()"
source_ref: "package/system/knulli-configgen/configgen/configgen/generators/aethersx2/aethersx2Generator.py:59,\
\ 93-94"
- emulator: gsplus
+27 -23
View File
@@ -3880,9 +3880,10 @@ launchers:
- capsimg.dll
case_insensitive: true
search_dir: bios
passes_to: copied to <retroarch>/<name>, only when the target does not exist
passes_to: <retroarch>/<name>
fails_when_missing: false
depends_on_option: core in capsimgCore, rom extension ".ipf"
depends_on_option: core in capsimgCore, rom extension ".ipf", the target does not
exist
source_ref: "emulatorLauncher/Generators/LibRetro.Generator.cs:1018-1027, 2750"
- emulator: demul
build: standalone
@@ -3943,11 +3944,11 @@ launchers:
- segaboot.gcm
case_insensitive: true
search_dir: bios
passes_to: first found copied to <emulator>/User/Triforce/segaboot.gcm, skipped
when the target is as large and not older
passes_to: <emulator>/User/Triforce/segaboot.gcm
fails_when_missing: "warning '[WARNING] Triforce: segaboot.gcm not found in bios\\\
triforce, test menu will not be available.' when the target is absent too"
depends_on_option: _triforce, !_crediar
depends_on_option: _triforce, !_crediar, first name found, the target is absent,
smaller or older
source_ref: "emulatorLauncher/Generators/Dolphin.Generator.cs:782-783, 1048-1083"
- emulator: dosbox-pure
build: standalone
@@ -3959,8 +3960,9 @@ launchers:
- Roland_SC-88.sf2
case_insensitive: true
search_dir: bios/scummvm/extra
passes_to: copied to <emulator>/system/<name>, only when the target does not exist
passes_to: <emulator>/system/<name>
fails_when_missing: false
depends_on_option: the target does not exist
source_ref: "emulatorLauncher/Generators/DosBoxPure.Generator.cs:125, 130-148"
- emulator: dosbox-staging
build: standalone
@@ -3970,9 +3972,9 @@ launchers:
- MT32_PCM.ROM
case_insensitive: true
search_dir: bios/scummvm/extra
passes_to: copied to <emulator>/mt32-roms/<name>, only when the target does not
exist
passes_to: <emulator>/mt32-roms/<name>
fails_when_missing: false
depends_on_option: the target does not exist
source_ref: "emulatorLauncher/Generators/DosBoxStaging.Generator.cs:321, 326-334"
- emulator: dosbox-staging
build: standalone
@@ -3982,9 +3984,9 @@ launchers:
- Roland_SC-88.sf2
case_insensitive: true
search_dir: bios/scummvm/extra
passes_to: copied to <emulator>/soundfonts/<name>, only when the target does not
exist
passes_to: <emulator>/soundfonts/<name>
fails_when_missing: false
depends_on_option: the target does not exist
source_ref: "emulatorLauncher/Generators/DosBoxStaging.Generator.cs:318, 336-344"
- emulator: duckstation
build: standalone
@@ -4426,9 +4428,9 @@ launchers:
names: *id001
case_insensitive: true
search_dir: bios/hatari/tos
passes_to: copied to bios/hatarib/<value>, only when the target does not exist
passes_to: bios/hatarib/<value>
fails_when_missing: false
depends_on_option: hatarib_tos
depends_on_option: hatarib_tos, the target does not exist
source_ref: "emulatorLauncher/Generators/LibRetro.CoreOptions.cs:3434-3435, 3457-3460"
- emulator: jzintv
build: standalone
@@ -4620,9 +4622,9 @@ launchers:
- syscard3.pce
case_insensitive: true
search_dir: bios
passes_to: "copied to <mesen>/Firmware/[BIOS] Super CD-ROM System (Japan) (v3.0).pce,\
\ only when the target does not exist"
passes_to: "<mesen>/Firmware/[BIOS] Super CD-ROM System (Japan) (v3.0).pce"
fails_when_missing: "ApplicationException 'PCE CD firmware is missing (syscard3.pce)'"
depends_on_option: the target does not exist
source_ref: "emulatorLauncher/Generators/Mesen.Generator.cs:254-269"
- emulator: mesen
build: standalone
@@ -4632,10 +4634,10 @@ launchers:
- SGB2.sfc
case_insensitive: true
search_dir: bios
passes_to: copied to <mesen>/Firmware/SGB1.sfc, SGB2.sfc, only when the target does
not exist
passes_to: <mesen>/Firmware/SGB1.sfc, <mesen>/Firmware/SGB2.sfc
fails_when_missing: "ApplicationException 'Super Gameboy firmware is missing (SGB1.sfc\
\ and/or SGB2.sfc)'"
depends_on_option: the target does not exist
source_ref: "emulatorLauncher/Generators/Mesen.Generator.cs:281-304"
- emulator: mesen
build: standalone
@@ -4644,9 +4646,9 @@ launchers:
- gba_bios.bin
case_insensitive: true
search_dir: bios
passes_to: copied to <mesen>/Firmware/gba_bios.bin, only when the target does not
exist
passes_to: <mesen>/Firmware/gba_bios.bin
fails_when_missing: false
depends_on_option: the target does not exist
source_ref: "emulatorLauncher/Generators/Mesen.Generator.cs:307-324"
- emulator: mesen
build: standalone
@@ -4655,8 +4657,9 @@ launchers:
- colecovision.rom
case_insensitive: true
search_dir: bios
passes_to: copied to <mesen>/Firmware/bios.col, only when the target does not exist
passes_to: <mesen>/Firmware/bios.col
fails_when_missing: "ApplicationException 'Colecovision firmware is missing (colecovision.rom)'"
depends_on_option: the target does not exist
source_ref: "emulatorLauncher/Generators/Mesen.Generator.cs:326-346"
- emulator: openmsx
build: standalone
@@ -5269,9 +5272,9 @@ launchers:
- kick40068.A4000
case_insensitive: true
search_dir: bios, bios/amiga, bios/Amiga
passes_to: copied to <WHDBoot>/Devs/Kickstarts/<name> from the system's Kickstart
candidates
passes_to: <WHDBoot>/Devs/Kickstarts/<name>
fails_when_missing: false
depends_on_option: the system's Kickstart candidates
source_ref: "emulatorLauncher/Generators/Uae.Generator.cs:1002-1011, 1026-1032"
- emulator: xemu
build: standalone
@@ -5343,9 +5346,10 @@ launchers:
- IPLROM30.DAT
case_insensitive: true
search_dir: bios/keropi
passes_to: copied to <emulator>/<name>, only when the target does not exist
passes_to: <emulator>/<name>
fails_when_missing: false
depends_on_option: "!string.IsNullOrEmpty(biosRoot), Directory.Exists(biosPath)"
depends_on_option: "!string.IsNullOrEmpty(biosRoot), Directory.Exists(biosPath),\
\ the target does not exist"
source_ref: "emulatorLauncher/Generators/Xm6pro.Generator.cs:118-141"
- emulator: xroar
build: standalone
+1 -1
View File
@@ -226,7 +226,7 @@
"scans_dir",
"package"
],
"description": "names_searched: tries the names in order, first found wins. fixed_name: writes or opens one path per name. core_option: sets a core option whose value names the file. passes_path: hands over a directory. links, copies: links or copies a file to where the emulator reads it. moves: moves a file the user left in one folder to the folder the launcher links from. scans_dir: the emulator picks from a directory. package: names no file, records what a package builds (`repo`) and the systems it registers (`systems`)"
"description": "names_searched: tries the names in order, first found wins. fixed_name: writes or opens one path per name. core_option: sets a core option whose value names the file. passes_path: hands over a directory. links, copies: links or copies a file to where the emulator reads it (a copy names its files under search_dir, the folder it copies from, passes_to is the target, and the case that skips it is in depends_on_option). moves: moves a file the user left in one folder to the folder the launcher links from. scans_dir: the emulator picks from a directory. package: names no file, records what a package builds (`repo`) and the systems it registers (`systems`)"
},
"package": {
"type": "string",
+27 -10
View File
@@ -332,7 +332,8 @@ class GenericSites:
"""A generator module whose BIOS path uses bios_sites() reads.
kind is fixed_name when the path names a file, passes_path when it names
a directory. A copy is recorded as `copies` whatever the kind. caller
a directory. A copy is recorded as `copies` whatever the kind, its names
read from the folder the code copies from and passes_to the target. caller
names the module and function whose core test selects the cores this
helper module runs for, emulator the name when no core test does.
"""
@@ -384,6 +385,8 @@ class _Site:
conditions: list[str]
cores: list[str]
expands: dict[str, list[str]] = field(default_factory=dict)
# A copy reads its file from `source` and hands it to `passes_to`.
source: str | None = None
class _Reader:
@@ -394,6 +397,7 @@ class _Reader:
self.src = src
self.bios = cg.bios.rstrip("/")
self.parents: dict[int, ast.AST] = {}
self.copy_sources: dict[int, str] = {}
for parent in ast.walk(src.tree):
for child in ast.iter_child_nodes(parent):
self.parents[id(child)] = parent
@@ -523,10 +527,14 @@ class _Reader:
continue
yield node, value # type: ignore[misc]
def conditions(self, node: ast.AST) -> tuple[list[str], list[tuple[int, int]]]:
def conditions(
self, node: ast.AST, guards: bool = False
) -> tuple[list[str], list[tuple[int, int]]]:
"""Tests of the ifs a node sits in, outermost first, and their lines.
A presence test of a BIOS path is a guard, kept for its lines only.
A presence test of a BIOS path is a guard, kept for its lines only,
unless guards is set: a copy made only when its source or its target
is there carries the test as a condition.
"""
tests: list[str] = []
spans: list[tuple[int, int]] = []
@@ -535,7 +543,7 @@ class _Reader:
if isinstance(parent, ast.If) and child is not parent.test:
spans.append(self.src.span(parent.test))
text = ast.unparse(parent.test)
if not self._is_guard(parent.test):
if guards or not self._is_guard(parent.test):
in_else = any(child is stmt for stmt in parent.orelse)
tests.append(f"not ({text})" if in_else else text)
child, parent = parent, self.parents.get(id(parent))
@@ -660,9 +668,10 @@ class _Reader:
receiver = func.value if isinstance(func, ast.Attribute) else None
if attr in ("copy", "copyfile", "copy2") and receiver is not None and ast.unparse(receiver) == "shutil":
source, target = parent.args[0], parent.args[1]
if source is node:
return f"copied to {self.resolve(target) or ast.unparse(target)}", "copies"
return f"copied from {self.resolve(source) or ast.unparse(source)}", "copies"
self.copy_sources[id(node)] = value if source is node else (
self.resolve(source) or ast.unparse(source)
)
return (value if target is node else self.resolve(target) or ast.unparse(target)), "copies"
if (
attr == "append"
and isinstance(receiver, ast.Name)
@@ -718,7 +727,7 @@ class _Reader:
def _site(
self, read: ast.AST, value: str, passes_to: str, kind: str | None, defined: ast.AST
) -> _Site:
tests, spans = self.conditions(read)
tests, spans = self.conditions(read, guards=kind == "copies")
lines = [self.src.span(self.statement_of(read)), *spans]
if defined is not read:
# A path bound in one branch and read after the if: the branch
@@ -726,7 +735,10 @@ class _Reader:
bound_tests, bound_spans = self.conditions(defined)
tests = list(dict.fromkeys([*bound_tests, *tests]))
lines += [self.src.span(self.statement_of(defined)), *bound_spans]
return _Site(value, passes_to, kind, lines, tests, self.cores(read), self._expands(read))
return _Site(
value, passes_to, kind, lines, tests, self.cores(read), self._expands(read),
self.copy_sources.get(id(read)),
)
def _guard_site(self, read: ast.AST, value: str) -> _Site:
"""A presence test whose if body is the only consumer of the path."""
@@ -802,7 +814,12 @@ def bios_sites(cg: Configgen, spec: GenericSites) -> list[Launcher]:
expands = {k: v for site in group for k, v in site.expands.items()}
names: list[str] = []
search_dir = path
if kind in ("fixed_name", "copies"):
source = group[0].source
if kind == "copies" and source is not None and not source.startswith(prefix):
# Copied into BIOS: the file is read from the folder it comes from.
search_dir, _, name = source.rpartition("/")
names = [name]
elif kind in ("fixed_name", "copies"):
if not path.startswith(prefix):
src.fail(f"{path} names no file under {cg.bios}")
names, search_dir = [path[len(prefix):]], cg.bios
+28 -17
View File
@@ -320,6 +320,16 @@ def feature_choices(
return values, src.lines(feature.start(), close + len("</feature>"))
# A copy record reads `names` from `search_dir` and hands them to the path in
# `passes_to`. When the copy is skipped is a condition of the record.
TARGET_ABSENT = "the target does not exist"
def _joined(*parts: str | None) -> str | None:
"""Conditions of a record, joined, None when there are none."""
return ", ".join(part for part in parts if part) or None
def _record(
emulator: str, build: str, kind: str, names: list[str], ref: str, **fields: object
) -> Record:
@@ -611,9 +621,9 @@ def hatari_libretro(sources: dict[str, Source]) -> list[Record]:
source_ref((src.path, [head, src.lines(shared.start(), copy.end())])),
case_insensitive=True,
search_dir="/".join(["bios", *folders]),
passes_to=f"copied to bios/{own.group(2)}/<value>, only when the target does not exist",
passes_to=f"bios/{own.group(2)}/<value>",
fails_when_missing=False,
depends_on_option=key,
depends_on_option=_joined(key, TARGET_ABSENT),
)
)
return records
@@ -661,10 +671,9 @@ def mesen(sources: dict[str, Source]) -> list[Record]:
source_ref((src.path, [src.statement_span(block)])),
case_insensitive=True,
search_dir="bios",
passes_to="copied to <mesen>/Firmware/"
+ ", ".join(destinations)
+ ", only when the target does not exist",
passes_to=", ".join(f"<mesen>/Firmware/{d}" for d in destinations),
fails_when_missing=fails,
depends_on_option=TARGET_ABSENT,
)
)
return records
@@ -804,11 +813,9 @@ def winuae(sources: dict[str, Source]) -> list[Record]:
),
case_insensitive=True,
search_dir=search,
passes_to=(
f"copied to <WHDBoot>/{'/'.join(_strings(folder.group(1)))}/<name>"
" from the system's Kickstart candidates"
),
passes_to=f"<WHDBoot>/{'/'.join(_strings(folder.group(1)))}/<name>",
fails_when_missing=False,
depends_on_option="the system's Kickstart candidates",
)
)
return records
@@ -2480,10 +2487,12 @@ def dolphin(sources: dict[str, Source]) -> list[Record]:
),
case_insensitive=True,
search_dir="bios",
passes_to=f"first found copied to {destination[0] if destination else target.group(1)},"
" skipped when the target is as large and not older",
passes_to=destination[0] if destination else target.group(1),
fails_when_missing=f"warning '{_unescape(warning.group(1))}' when the target is absent too",
depends_on_option=", ".join(conditions) or None,
depends_on_option=_joined(
*conditions, "first name found",
"the target is absent, smaller or older",
),
)
)
return _covered(src, records, skip=(r"DolphinTool\.exe",))
@@ -2578,9 +2587,9 @@ def _copies_if_absent(
_path_parts(src, *returns, *[s for c in copies for s in c[3]]),
case_insensitive=True,
search_dir=folders.pop(),
passes_to=f"copied to {destination}/<name>, only when the target does not exist",
passes_to=f"{destination}/<name>",
fails_when_missing=False,
depends_on_option=", ".join(conditions) or None,
depends_on_option=_joined(*conditions, TARGET_ABSENT),
)
)
if not records:
@@ -2619,7 +2628,9 @@ def capsimg(sources: dict[str, Source]) -> list[Record]:
record["source_ref"] = source_ref(
(src.path, [(src.line(cores.start()), core_lines[1]), src.statement_span(block)])
)
record["depends_on_option"] = f'core in {cores.group(1)}, rom extension "{gate.group(1)}"'
record["depends_on_option"] = _joined(
f'core in {cores.group(1)}, rom extension "{gate.group(1)}"', TARGET_ABSENT
)
return [record]
@@ -3108,9 +3119,9 @@ def xm6pro(sources: dict[str, Source]) -> list[Record]:
_path_parts(src, (src.line(table.start()), lines[1]), src.lines(method.start(), body[1])),
case_insensitive=True,
search_dir=folder[0],
passes_to="copied to <emulator>/<name>, only when the target does not exist",
passes_to="<emulator>/<name>",
fails_when_missing=False,
depends_on_option=", ".join(conditions) or None,
depends_on_option=_joined(*conditions, TARGET_ABSENT),
)
return _covered(src, [record])
+3 -2
View File
@@ -451,8 +451,9 @@ class BiosSites(unittest.TestCase):
(record,) = sites("ryujinx/ryujinxGenerator.py", "fixed_name")
self.assertEqual(record["kind"], "copies")
self.assertEqual(record["names"], ["switch/prod.keys"])
self.assertEqual(record["passes_to"], "copied to /userdata/system/configs/Ryujinx/system/prod.keys")
self.assertNotIn("depends_on_option", record)
self.assertEqual(record["search_dir"], "/userdata/bios")
self.assertEqual(record["passes_to"], "/userdata/system/configs/Ryujinx/system/prod.keys")
self.assertEqual(record["depends_on_option"], "ryujinxKeys.exists()")
def test_a_presence_test_over_a_literal_list(self):
(record,) = sites("dolphin/dolphinGenerator.py", "fixed_name")
+7 -3
View File
@@ -1042,7 +1042,10 @@ class HelpersAndCopies(unittest.TestCase):
)
self.assertEqual(segaboot["kind"], "copies")
self.assertEqual(segaboot["names"], ["triforce/segaboot.gcm", "segaboot.gcm"])
self.assertEqual(segaboot["depends_on_option"], "_triforce, !_crediar")
self.assertEqual(
segaboot["depends_on_option"],
"_triforce, !_crediar, first name found, the target is absent, smaller or older",
)
def test_dosbox_copies_grouped_by_target_folder(self):
sources = _sources(
@@ -1055,16 +1058,17 @@ class HelpersAndCopies(unittest.TestCase):
(
"dosbox-pure",
["MT32_CONTROL.ROM"],
"copied to <emulator>/mt32-roms/<name>, only when the target does not exist",
"<emulator>/mt32-roms/<name>",
),
(
"dosbox-pure",
["Roland_SC-55.sf2"],
"copied to <emulator>/soundfonts/<name>, only when the target does not exist",
"<emulator>/soundfonts/<name>",
),
],
)
self.assertEqual(records[0]["search_dir"], "bios/scummvm/extra")
self.assertEqual(records[0]["depends_on_option"], "the target does not exist")
def test_tsugaru_folder_chain(self):
[record] = rl.tsugaru(_sources(**{f"{G}/Tsugaru.Generator.cs": TSUGARU}))
+2 -1
View File
@@ -491,7 +491,8 @@ the launcher's name, a file the core accepts: the file the collection knows
under that name when the core takes its content, otherwise the copy held for
the core entry read under that name. Records that only hand over a directory
(`passes_path`, `scans_dir`, a `links` record with no names) or copy a file
the platform already names (`copies`) place nothing, and a `search_dir` the
the platform already names (`copies`, its `names` under the `search_dir` it
copies from, the target in `passes_to`) place nothing, and a `search_dir` the
code describes rather than names ("directory of the Chihiro BIOS found") is
skipped. `$biosdir`, `${biosPath}` and `$bios_path` are the BIOS directory as
RetroPie, EmuDeck and RetroDECK spell it, and a path from a root