mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-13 13:33:52 -06:00
chore: merge origin/main
This commit is contained in:
commit
b56dc64609
32 files changed
+744
-81
No files matched your search
@@ -46,6 +46,7 @@ on:
|
||||
- "scripts/restore_large_files.py"
|
||||
- "scripts/safeparse.py"
|
||||
- "scripts/sect233r1.py"
|
||||
- "scripts/siteagents.py"
|
||||
- "scripts/sitebootstrap.py"
|
||||
- "scripts/sitecollection.py"
|
||||
- "scripts/sitediagrams.py"
|
||||
|
||||
@@ -51,10 +51,12 @@ data_directories:
|
||||
- ref: fbneo-cheats
|
||||
unread_on: [romm]
|
||||
destination: fbneo/cheats
|
||||
required: false
|
||||
source_ref: "source src/burner/conc.cpp:167"
|
||||
- ref: fbneo-samples
|
||||
unread_on: [romm]
|
||||
destination: fbneo
|
||||
required: false
|
||||
source_ref: "source src/burn/snd/samples.cpp:21-35"
|
||||
|
||||
notes: |
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
|
||||
# the site's build rewrites this line there (scripts/sitebootstrap.py).
|
||||
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
$defaultInstallSha256 = "57e3cb6739a701181908773e603f17d2b10e1b0e2a5cde5cc4d5c97426da5dd4"
|
||||
$defaultInstallSha256 = "3a78bf2e5a910d0f5c21bc2dc490aa600fc3273c56ae01e9ce5061d9b3578f07"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+25
-8
@@ -3724,6 +3724,8 @@ def _check_data_directory(entry: object, where: str) -> None:
|
||||
values = entry.get(field, [])
|
||||
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
|
||||
raise ValueError(f"{where}.{field} is invalid")
|
||||
if entry.get("optional", True) is not True:
|
||||
raise ValueError(f"{where}.optional is invalid")
|
||||
files = entry.get("files")
|
||||
if not isinstance(files, dict) or not files or len(files) > MAX_MANIFEST_FILES:
|
||||
raise ValueError(f"{where}.files is invalid")
|
||||
@@ -7393,11 +7395,16 @@ def _install_data_member(
|
||||
scratch.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def select_data_directories(manifest: dict, files: list[dict], narrowed: bool) -> list[dict]:
|
||||
"""The data trees this install keeps: all, or those of a kept system."""
|
||||
entries = manifest.get("data_directories") or []
|
||||
def select_data_directories(manifest: dict, files: list[dict], narrowed: bool,
|
||||
required_only: bool = False) -> list[dict]:
|
||||
"""The data trees this install keeps: all, or those of a kept system.
|
||||
--required-only leaves out a tree every core reading it runs without
|
||||
(`optional`): a GBA and a SNES game took FBNeo's cheats and samples,
|
||||
3,472 files no game opened."""
|
||||
entries = [e for e in manifest.get("data_directories") or []
|
||||
if not (required_only and e.get("optional"))]
|
||||
if not narrowed:
|
||||
return list(entries)
|
||||
return entries
|
||||
kept = {system for f in files for system in entry_systems(f)}
|
||||
return [e for e in entries if kept & set(e.get("systems") or [])]
|
||||
|
||||
@@ -8824,7 +8831,8 @@ def _prompt_custom_selection(
|
||||
if bios_path is not None and not dry_run and current != INSTALLED_ONLY:
|
||||
kept = menu_choice(files, omitted, installed, current, False)[0]
|
||||
kept_trees = select_data_directories({"data_directories": trees}, kept,
|
||||
narrowed or current is not None)
|
||||
narrowed or current is not None,
|
||||
isinstance(current, GamesChoice) and current.required)
|
||||
if kept and all_in_place(kept, kept_trees, bios_path):
|
||||
# Read before the menu, as the screen reads before it asks: a
|
||||
# rerun was offered an install that installs nothing, then told
|
||||
@@ -8953,7 +8961,8 @@ def _print_menu_head(
|
||||
# The trees of the systems kept: 2636 data files of blueMSX, Dolphin
|
||||
# and PPSSPP were counted for every --system tried.
|
||||
kept_trees = select_data_directories({"data_directories": trees}, shown,
|
||||
narrowed or current is not None)
|
||||
narrowed or current is not None,
|
||||
isinstance(current, GamesChoice) and current.required)
|
||||
data = sum(len(entry.get("files") or {}) for entry in kept_trees)
|
||||
data_size = sum(meta.get("size", 0) for entry in kept_trees
|
||||
for meta in (entry.get("files") or {}).values())
|
||||
@@ -9764,6 +9773,12 @@ def games_setups(index: dict, plat: str, bios_path: Path, os_type: str = "") ->
|
||||
if not named:
|
||||
above = card_root_above(bios_path)
|
||||
named = above[0] if above else ""
|
||||
if not named:
|
||||
# --dest on a handheld's games part from a computer (ArkOS's
|
||||
# EASYROMS/bios): detection never ran there, and the card's own rom
|
||||
# folders sit beside the BIOS folder.
|
||||
part = games_part(bios_path.parent)
|
||||
named = part.name if part else ""
|
||||
key = _setup_key(named) if named else ""
|
||||
found = [name for name in sorted(setups) if key and (name == key or name.startswith(key + "-"))]
|
||||
if not found and plat in setups:
|
||||
@@ -15671,7 +15686,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
!= len(manifest.get("files", [])) + len(variants if case_kept else [])
|
||||
or games_applied
|
||||
)
|
||||
data_dirs = select_data_directories(manifest, files, narrowed)
|
||||
data_dirs = select_data_directories(manifest, files, narrowed,
|
||||
bool(args.required_only))
|
||||
# The kinds as the plan and the menu head say them (parts_words),
|
||||
# the data files of the folders kept among them: this head said
|
||||
# BIOS files and game data beside a plan of three kinds, neither
|
||||
@@ -21812,7 +21828,8 @@ def _plan_data(state: TuiState, check: dict) -> tuple[int, int | None]:
|
||||
if not _tui_estimating(state):
|
||||
data = check.get("data_missing", 0)
|
||||
return data, check.get("data_bytes") if data else None
|
||||
trees = select_data_directories(state.manifest or {}, _choice_entries(state), True)
|
||||
required = any(flag == "--required-only" for flag, _value in tui_flags(state))
|
||||
trees = select_data_directories(state.manifest or {}, _choice_entries(state), True, required)
|
||||
sizes = [meta.get("size", 0) for entry in trees for meta in (entry.get("files") or {}).values()]
|
||||
return len(sizes), sum(sizes)
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
|
||||
# curl or wget stop on a cut transfer before the hash is read: the site's
|
||||
# build rewrites this line there (scripts/sitebootstrap.py).
|
||||
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
|
||||
DEFAULT_INSTALL_SHA256="57e3cb6739a701181908773e603f17d2b10e1b0e2a5cde5cc4d5c97426da5dd4"
|
||||
DEFAULT_INSTALL_SHA256="3a78bf2e5a910d0f5c21bc2dc490aa600fc3273c56ae01e9ce5061d9b3578f07"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "amberelec",
|
||||
"display_name": "AmberELEC",
|
||||
"version": "prerelease-20261019_1835",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
@@ -77381,7 +77381,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -77615,7 +77616,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -77988,7 +77990,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "batocera",
|
||||
"display_name": "Batocera",
|
||||
"version": "43.1",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
{
|
||||
@@ -225291,7 +225291,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -225525,7 +225526,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"save_directory": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "emudeck",
|
||||
"display_name": "EmuDeck",
|
||||
"version": "2.7EA",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
{
|
||||
@@ -123400,7 +123400,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -123634,7 +123635,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -124007,7 +124009,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "lrps2",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "emuelec",
|
||||
"display_name": "EmuELEC",
|
||||
"version": "4.8",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
@@ -83639,7 +83639,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -83873,7 +83874,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -84246,7 +84248,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-3
@@ -6,7 +6,7 @@
|
||||
"platform": "knulli",
|
||||
"display_name": "KNULLI",
|
||||
"version": "20260511",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
{
|
||||
@@ -157933,7 +157933,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -158167,7 +158168,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "lrps2",
|
||||
|
||||
+7
-4
@@ -6,7 +6,7 @@
|
||||
"platform": "lakka",
|
||||
"display_name": "Lakka",
|
||||
"version": "v6.1",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "system",
|
||||
"detect": [
|
||||
{
|
||||
@@ -130677,7 +130677,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -130911,7 +130912,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -131284,7 +131286,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "yaps2-resources",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "myminui",
|
||||
"display_name": "MyMinUI",
|
||||
"version": "2026-08-09",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
@@ -18958,7 +18958,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-cheats",
|
||||
@@ -32688,7 +32689,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -32922,7 +32924,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -33156,7 +33159,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-3
@@ -6,7 +6,7 @@
|
||||
"platform": "nextui",
|
||||
"display_name": "NextUI",
|
||||
"version": "2026-10-20",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
@@ -19366,7 +19366,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -19600,7 +19601,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "recalbox",
|
||||
"display_name": "Recalbox",
|
||||
"version": "10.1.1",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
{
|
||||
@@ -62834,7 +62834,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -63068,7 +63069,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"save_directory": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "retroarch",
|
||||
"display_name": "RetroArch",
|
||||
"version": "v1.22.2",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "system",
|
||||
"detect": [
|
||||
{
|
||||
@@ -140789,7 +140789,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -141023,7 +141024,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -141396,7 +141398,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "ppsspp-assets",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "retrobat",
|
||||
"display_name": "RetroBat",
|
||||
"version": "8.3.1",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
{
|
||||
@@ -89076,7 +89076,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -89310,7 +89311,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -89683,7 +89685,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"save_directory": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "retrodeck",
|
||||
"display_name": "RetroDECK",
|
||||
"version": "0.10.10b",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "",
|
||||
"detect": [
|
||||
{
|
||||
@@ -153141,7 +153141,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -153375,7 +153376,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -153748,7 +153750,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "yaps2-resources",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "retropie",
|
||||
"display_name": "RetroPie",
|
||||
"version": "4.8.12",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "BIOS",
|
||||
"detect": [
|
||||
{
|
||||
@@ -146474,7 +146474,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -146708,7 +146709,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -147081,7 +147083,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "yaps2-resources",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"platform": "rocknix",
|
||||
"display_name": "ROCKNIX",
|
||||
"version": "20261001",
|
||||
"generated": "2026-11-05T23:59:37Z",
|
||||
"generated": "2026-11-06T07:21:27Z",
|
||||
"base_destination": "bios",
|
||||
"detect": [
|
||||
{
|
||||
@@ -109453,7 +109453,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 5083767
|
||||
"archive_size": 5083767,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "fbneo-samples",
|
||||
@@ -109687,7 +109688,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 0,
|
||||
"archive_size": 53006540
|
||||
"archive_size": 53006540,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "freeintv-overlays",
|
||||
@@ -110060,7 +110062,8 @@
|
||||
}
|
||||
},
|
||||
"strip_components": 1,
|
||||
"archive_size": 21311170
|
||||
"archive_size": 21311170,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"ref": "lrps2",
|
||||
|
||||
@@ -452,6 +452,11 @@
|
||||
},
|
||||
"description": "Platform systems whose cores read the tree."
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"const": true,
|
||||
"description": "Every core reading the tree runs without it (required: false in its profile): --required-only leaves it out."
|
||||
},
|
||||
"files": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
|
||||
@@ -518,7 +518,8 @@ def _data_directory_members(
|
||||
gets it from the tree's archive.
|
||||
|
||||
``refs_out``, when given, maps each yielded destination to
|
||||
(registry key, system ids, directory destination), for the manifest.
|
||||
(registry key, system ids, directory destination, whether the core
|
||||
needs the tree), for the manifest.
|
||||
|
||||
These are not BIOS and carry no hash of their own: they are whole
|
||||
trees an emulator reads, refreshed from upstream, so what ships is
|
||||
@@ -532,7 +533,8 @@ def _data_directory_members(
|
||||
ref_key = dd.get("ref", "")
|
||||
if not ref_key or not data_registry or ref_key not in data_registry:
|
||||
continue
|
||||
if required_only and dd.get("required") is False:
|
||||
needed = dd.get("required") is not False
|
||||
if required_only and not needed:
|
||||
continue
|
||||
if ref_key in installed_data_refs(platform_name):
|
||||
continue
|
||||
@@ -567,7 +569,7 @@ def _data_directory_members(
|
||||
):
|
||||
del reclaim[full]
|
||||
if refs_out is not None:
|
||||
refs_out[full] = (ref_key, systems, dd_prefix)
|
||||
refs_out[full] = (ref_key, systems, dd_prefix, needed)
|
||||
yield src, full
|
||||
continue
|
||||
if full in seen_destinations or (
|
||||
@@ -581,7 +583,7 @@ def _data_directory_members(
|
||||
if case_insensitive:
|
||||
seen_lower.add(full.lower())
|
||||
if refs_out is not None:
|
||||
refs_out[full] = (ref_key, systems, dd_prefix)
|
||||
refs_out[full] = (ref_key, systems, dd_prefix, needed)
|
||||
yield src, full
|
||||
|
||||
|
||||
@@ -4910,7 +4912,7 @@ def generate_manifest(
|
||||
# Phase 3: data directories. They are not files of the collection: the
|
||||
# installer fetches each from its source archive and keeps the members
|
||||
# whose hash the list below records, the same tree the pack carries.
|
||||
data_refs: dict[str, tuple[str, tuple[str, ...], str]] = {}
|
||||
data_refs: dict[str, tuple[str, tuple[str, ...], str, bool]] = {}
|
||||
reclaim = {full: path for full, (path, _size) in cache_held.items()}
|
||||
data_members = list(_data_directory_members(
|
||||
pack_systems, data_registry, platform_name, base_dest,
|
||||
@@ -5021,7 +5023,7 @@ def generate_manifest(
|
||||
|
||||
def _manifest_data_directories(
|
||||
members: list[tuple[str, str]],
|
||||
refs: dict[str, tuple[str, tuple[str, ...], str]],
|
||||
refs: dict[str, tuple[str, tuple[str, ...], str, bool]],
|
||||
registry: dict,
|
||||
base_dest: str,
|
||||
) -> list[dict]:
|
||||
@@ -5034,11 +5036,14 @@ def _manifest_data_directories(
|
||||
changed since is refused rather than installed. archive_size is the
|
||||
size of the archive refresh_data_dirs last downloaded, what fetching
|
||||
the tree costs: a tarball whose tree is 10 KB is a download of 21 MB.
|
||||
``optional`` marks a tree every core reading it runs without
|
||||
(``required: false``): install.py --required-only leaves it out, as the
|
||||
pack built with --required-only does.
|
||||
"""
|
||||
grouped: dict[tuple[str, str], dict] = {}
|
||||
versions: dict[str, dict[str, dict]] = {}
|
||||
for src, full in members:
|
||||
ref, systems, prefix = refs[full]
|
||||
ref, systems, prefix, needed = refs[full]
|
||||
entry = registry.get(ref, {})
|
||||
key = (ref, prefix)
|
||||
group = grouped.get(key)
|
||||
@@ -5072,11 +5077,19 @@ def _manifest_data_directories(
|
||||
size = versions[str(recorded)].get(ref, {}).get("archive_size")
|
||||
if isinstance(size, int) and size > 0:
|
||||
group["archive_size"] = size
|
||||
if needed:
|
||||
group["needed"] = True
|
||||
group["systems"].extend(s for s in systems if s not in group["systems"])
|
||||
rel = full[len(prefix):].lstrip("/") if prefix else full
|
||||
hashes = compute_hashes(src, frozenset({"sha1"}))
|
||||
group["files"][rel] = {"sha1": hashes["sha1"], "size": os.path.getsize(src)}
|
||||
return [grouped[key] for key in sorted(grouped)]
|
||||
trees = []
|
||||
for key in sorted(grouped):
|
||||
group = grouped[key]
|
||||
if not group.pop("needed", False):
|
||||
group["optional"] = True
|
||||
trees.append(group)
|
||||
return trees
|
||||
|
||||
|
||||
def _manifest_core_index(
|
||||
|
||||
@@ -32,7 +32,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
@@ -92,6 +92,7 @@ import sitemarkup as markup
|
||||
import siteentries
|
||||
import siteinputs
|
||||
import retired_files
|
||||
import siteagents
|
||||
import sitestarlight
|
||||
from siteexports import generate_exports_page, load_export_reports
|
||||
from siteguides import (
|
||||
@@ -7907,7 +7908,7 @@ def generate_emulators_index(profiles: dict) -> str:
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _snippet_noun(names, firmware: int, fetched: int) -> str:
|
||||
def _snippet_noun(names: Iterable[str], firmware: int, fetched: int) -> str:
|
||||
"""What an emulator's files are called in its snippet. A Switch
|
||||
emulator reads keys, and "eden keys" is what readers search for; Ship
|
||||
of Harkinian reads game ROMs, which are no BIOS."""
|
||||
@@ -12368,6 +12369,9 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
stats = compute_stats(db, coverages, profiles)
|
||||
stats["composition"] = compute_composition(db)
|
||||
write_if_changed(str(public / "stats.json"), generate_stats(stats))
|
||||
# llms.txt and the ARD manifest an agent reads first, from the registry.
|
||||
siteagents.write(public, sorted(((name, cov["platform"]) for name, cov in coverages.items()),
|
||||
key=lambda row: row[1].casefold()), write_if_changed)
|
||||
|
||||
# Computed once: the gap analysis page and the published gaps export must
|
||||
# report the same files.
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""What the site tells an AI agent about itself.
|
||||
|
||||
- ``llms.txt`` (https://llmstxt.org): the pages and data an agent should
|
||||
read first, in Markdown, at the site's root.
|
||||
- ``skills/setup-bios/SKILL.md``: an Agent Skill that sets up a device's
|
||||
BIOS files with the fewest downloads.
|
||||
- ``ard.json``: an Agentic Resource Discovery manifest
|
||||
(https://github.com/ards-project/ard-spec) listing that skill and the
|
||||
JSON API, linked from every page's head with ``rel="ard"``.
|
||||
|
||||
Both are written from the registry at each build, so a new platform or a
|
||||
moved page cannot leave them stale. The WebMCP tools a page registers live
|
||||
in website/src/scripts/agent-tools.ts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
SITE = "https://abdess.github.io/retrobios/"
|
||||
PUBLISHER = "abdess.github.io"
|
||||
SH = "curl -fsSL https://abdess.github.io/retrobios/i | sh -s --"
|
||||
PS = "& ([scriptblock]::Create((irm https://abdess.github.io/retrobios/i.ps1)))"
|
||||
# What a folder put into a command may not hold, as agent-tools.ts refuses
|
||||
# it: PowerShell also ends a single-quoted string on the typographic quotes.
|
||||
REFUSED = "any quote, straight or typographic (' \" ‘ ’ ‚ ‛ “ ” „), a backtick or a control character"
|
||||
QUOTING = (f"A folder goes in single quotes, attached to its option. One that holds {REFUSED}, "
|
||||
"or starts with `-`, is a question for the user, never a value to escape into the "
|
||||
"command. On a page of the site, the `device_setup_command` WebMCP tool writes the "
|
||||
"command and refuses such a folder.")
|
||||
|
||||
START_PAGES = (
|
||||
("Which pack do I need", "which-pack/", "pick a device or setup, get the folder its BIOS files go in, "
|
||||
"one command that installs and checks them, or a ZIP pack"),
|
||||
("First install", "wiki/first-install/", "the steps from nothing to a working BIOS folder"),
|
||||
("What are BIOS files", "wiki/what-are-bios-files/", "why emulators need them and where they come from"),
|
||||
("Troubleshooting", "wiki/troubleshooting/", "a file the emulator does not find or refuses"),
|
||||
("Common questions", "wiki/faq/", "short answers"),
|
||||
)
|
||||
|
||||
DATA = (
|
||||
("API index", "api/v1/index.json", "every dataset of the JSON API, versioned"),
|
||||
("Catalog", "api/v1/catalog.json", "each dataset, manifest, export and download with its size, SHA-256 and schema"),
|
||||
("Emulators", "api/v1/emulators.json", "every emulator profile: the files it reads, required or not, "
|
||||
"their hashes and the source line that reads each. One document "
|
||||
"per emulator at api/v1/emulators/<id>.json"),
|
||||
("Core names", "api/v1/cores.json", "each core name and alias, with the profile it designates"),
|
||||
("Systems", "api/v1/systems.json", "each system id, its name and the section holding its files"),
|
||||
("Platforms", "api/v1/platforms.json", "each setup (RetroArch, Batocera...), its BIOS folder and files"),
|
||||
("Hash lookup", "api/v1/lookup/00.json", "what a hash (MD5, SHA-1, SHA-256, CRC32) designates, sharded "
|
||||
"by its first two hex characters: api/v1/lookup/<xx>.json"),
|
||||
)
|
||||
|
||||
MORE = (
|
||||
("Emulators", "emulators/", "one page per emulator"),
|
||||
("Systems", "systems/", "one page per console maker, every file with its hashes"),
|
||||
("Installer", "wiki/installer/", "every option of install.py"),
|
||||
("What we count", "wiki/privacy/", "the site's cookie-free counting"),
|
||||
("Is it legal?", "wiki/legal/", "the project's position"),
|
||||
("Contributing", "contributing/", "add a missing file or an emulator profile"),
|
||||
)
|
||||
|
||||
|
||||
def llms_txt(platforms: list[tuple[str, str]]) -> str:
|
||||
"""The site's llms.txt. ``platforms`` lists (id, display name) of every
|
||||
registered setup, each with its page."""
|
||||
lines = [
|
||||
"# RetroBIOS",
|
||||
"",
|
||||
"> BIOS and firmware files for emulators, each checked against the emulator's source "
|
||||
"code: which files an emulator reads, whether it starts without them, where each one "
|
||||
"goes on every setup, their hashes, and an installer that puts them in place.",
|
||||
"",
|
||||
"Every claim about a file cites the line of emulator source code that reads it. The "
|
||||
"files themselves are served by the GitHub repository "
|
||||
"(https://github.com/Abdess/retrobios) and its releases.",
|
||||
"",
|
||||
"## Setting up a device for its user",
|
||||
"",
|
||||
"Most people want to play now, with the fewest files. The installer does that in one "
|
||||
"command: it finds the setup and its BIOS folder, reads the games on the card, and with "
|
||||
"`--for-games --required-only` installs only the files those games cannot start "
|
||||
"without. Without these two options it installs every file of the setup's list "
|
||||
"(gigabytes), even with `--yes`.",
|
||||
"",
|
||||
f"1. Preview, nothing written: `{SH} --for-games --required-only --dry-run`",
|
||||
f"2. Install, no questions: `{SH} --for-games --required-only --yes`",
|
||||
f"3. On Windows (PowerShell): `{PS} --for-games --required-only --yes`",
|
||||
"",
|
||||
"- Run it on the device itself (its terminal or SSH), or on a computer with the device's "
|
||||
"card plugged in. The installer recognises the handheld systems on the card, among them "
|
||||
"ArkOS, dArkOS, ArchR and UnofficialOS (R36S and its clones), muOS, KNULLI, ROCKNIX, "
|
||||
"AmberELEC, EmuELEC, Onion, spruceOS and MinUI. "
|
||||
"The [Linux handhelds page](" + SITE + "platforms/linux-handhelds/) maps each device "
|
||||
"to its system and folder.",
|
||||
"- From a computer, if the card is not found, name its BIOS folder: `--dest='<card>/bios'` "
|
||||
"(ArkOS and dArkOS: the `bios` folder of the EASYROMS part).",
|
||||
"- A games folder somewhere else: `--for-games='<folder>'`, one subfolder per system.",
|
||||
f"- {QUOTING}",
|
||||
"- Later, to see what is missing or wrong: `--check`. Machine-readable progress: "
|
||||
"`--events` (JSON lines, one per event, documented on the installer page).",
|
||||
"- A game the installer cannot identify gets every BIOS of its system, and the run names it.",
|
||||
f"- The same steps as an Agent Skill: [SKILL.md]({SITE}{SKILL_PATH}).",
|
||||
"",
|
||||
"## Start",
|
||||
"",
|
||||
*(f"- [{title}]({SITE}{path}): {what}" for title, path, what in START_PAGES),
|
||||
"",
|
||||
"## Setups",
|
||||
"",
|
||||
*(f"- [{name}]({SITE}platforms/{pid}/): its BIOS folder and files" for pid, name in platforms),
|
||||
"",
|
||||
"## Data",
|
||||
"",
|
||||
*(f"- [{title}]({SITE}{path}): {what}" for title, path, what in DATA),
|
||||
"",
|
||||
"## Optional",
|
||||
"",
|
||||
*(f"- [{title}]({SITE}{path}): {what}" for title, path, what in MORE),
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
SKILL_PATH = "skills/setup-bios/SKILL.md"
|
||||
# The Agent Skills media type ARD names: Markdown with this profile.
|
||||
SKILL_TYPE = 'text/markdown; profile="urn:air:agent-skills"'
|
||||
|
||||
|
||||
def skill_md() -> str:
|
||||
"""An Agent Skill (SKILL.md: YAML front matter, then instructions) that
|
||||
sets up a device's BIOS files with the fewest downloads."""
|
||||
return "\n".join([
|
||||
"---",
|
||||
"name: setup-bios",
|
||||
"description: Install the BIOS and firmware files a retrogaming device or computer needs "
|
||||
"(RetroArch, Batocera, EmuDeck, ArkOS, dArkOS, muOS, KNULLI, ROCKNIX and others), only "
|
||||
"what the games on its card need to start. Use when a user asks to set up emulators, "
|
||||
"a handheld (R36S, Miyoo, Anbernic), BIOS files or firmware.",
|
||||
"---",
|
||||
"",
|
||||
"# Set up BIOS files with RetroBIOS",
|
||||
"",
|
||||
"Most users want to play now, with the fewest files. Install only what the games on the "
|
||||
"card need, unless the user asks for everything.",
|
||||
"",
|
||||
"## Steps",
|
||||
"",
|
||||
"1. Ask where the games are: on the device itself (you have its terminal or SSH), or on "
|
||||
"its card plugged into this computer.",
|
||||
f"2. Preview, nothing is written: `{SH} --for-games --required-only --dry-run` "
|
||||
f"(Windows PowerShell: `{PS} --for-games --required-only --dry-run`).",
|
||||
"3. Show the user the plan it prints: the setup found, the BIOS folder, the files and "
|
||||
"their size. Ask before going on if the setup or the folder is not the one expected.",
|
||||
"4. Install with the same command, `--yes` in place of `--dry-run`.",
|
||||
"5. Report the summary line. A game the installer could not identify got every BIOS of "
|
||||
"its system: say which.",
|
||||
"",
|
||||
"## When the setup or the card is not found",
|
||||
"",
|
||||
"- From a computer, name the card's BIOS folder: `--dest='<card>/bios'`. ArkOS and dArkOS "
|
||||
"keep it on the EASYROMS part.",
|
||||
"- Games outside the card: `--for-games='<folder>'`, one subfolder per system.",
|
||||
"- A setup not detected: `--platform=<name>`, a name exactly as `--list-platforms` prints "
|
||||
"it (letters, digits, `-` and `_`).",
|
||||
f"- {QUOTING}",
|
||||
"",
|
||||
"## Later",
|
||||
"",
|
||||
"- `--check` says what is missing or wrong, without downloading.",
|
||||
"- `--events` writes the run as JSON lines, for reading by a program.",
|
||||
"",
|
||||
f"Every option: {SITE}wiki/installer/. Devices and their folders: "
|
||||
f"{SITE}platforms/linux-handhelds/. The site's guide for agents: {SITE}llms.txt.",
|
||||
"",
|
||||
])
|
||||
|
||||
|
||||
def ard_manifest() -> dict:
|
||||
"""The ARD manifest: the setup skill and the JSON API, each with the
|
||||
questions it answers. llms.txt is found at the site root, as its
|
||||
convention says."""
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"identifier": f"urn:air:{PUBLISHER}:retrobios:setup-bios",
|
||||
"displayName": "Set up BIOS files with RetroBIOS",
|
||||
"type": SKILL_TYPE,
|
||||
"url": f"{SITE}{SKILL_PATH}",
|
||||
"description": "An agent skill that installs the BIOS files a device needs, only "
|
||||
"what the games on its card need to start.",
|
||||
"representativeQueries": [
|
||||
"install the minimum BIOS files for the games on my R36S running dArkOS",
|
||||
"set up BIOS files for RetroArch on my Steam Deck",
|
||||
"my emulator says BIOS missing, fix it",
|
||||
],
|
||||
"capabilities": ["SetupBios"],
|
||||
"tags": ["bios", "firmware", "emulation", "retrogaming"],
|
||||
},
|
||||
{
|
||||
"identifier": f"urn:air:{PUBLISHER}:retrobios:api",
|
||||
"displayName": "RetroBIOS JSON API",
|
||||
"type": "application/json",
|
||||
"url": f"{SITE}api/v1/index.json",
|
||||
"description": "Versioned JSON datasets: emulator profiles, platforms, systems, core "
|
||||
"names and a hash lookup.",
|
||||
"representativeQueries": [
|
||||
"what file has the MD5 490f666e1afb15b7362b406ed1cea246",
|
||||
"which files does the beetle_psx core read",
|
||||
"where do PlayStation BIOS files go on Batocera",
|
||||
],
|
||||
"capabilities": ["HashLookup", "EmulatorFiles", "PlatformFiles"],
|
||||
"tags": ["api", "json", "bios"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def write(public: Path, platforms: list[tuple[str, str]],
|
||||
write_if_changed: Callable[[str, str], object]) -> None:
|
||||
write_if_changed(str(public / "llms.txt"), llms_txt(platforms))
|
||||
(public / SKILL_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
write_if_changed(str(public / SKILL_PATH), skill_md())
|
||||
write_if_changed(str(public / "ard.json"), json.dumps(ard_manifest(), indent=1) + "\n")
|
||||
@@ -6923,9 +6923,11 @@ class RequiredOnlyReachesDataDirectories(unittest.TestCase):
|
||||
|
||||
def test_every_tree_walk_reads_the_flag(self):
|
||||
source = (ROOT / "scripts" / "generate_pack.py").read_text(encoding="utf-8")
|
||||
self.assertEqual(
|
||||
source.count('if required_only and dd.get("required") is False:'), 2
|
||||
)
|
||||
# The pack's walk and the manifest's, which records the flag for
|
||||
# install.py --required-only.
|
||||
self.assertEqual(source.count('if required_only and dd.get("required") is False:'), 1)
|
||||
self.assertIn('needed = dd.get("required") is not False\n if required_only and not needed:',
|
||||
source)
|
||||
self.assertIn("seen_parents, required_only=required_only,", source)
|
||||
self.assertIn("data_refs, required_only=required_only,", source)
|
||||
|
||||
|
||||
@@ -3880,6 +3880,20 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
def test_required_only_leaves_out_an_optional_tree(self):
|
||||
"""A GBA and a SNES game with --for-games --required-only took
|
||||
FBNeo's cheats and samples, 3,472 files no game opened."""
|
||||
cheats = {**self._entry(), "ref": "fbneo-cheats", "systems": ["snes"], "optional": True}
|
||||
sys_tree = {**self._entry(), "systems": ["snes"]}
|
||||
manifest = {"data_directories": [cheats, sys_tree]}
|
||||
files = [{"dest": "a.rom", "systems": ["snes"]}]
|
||||
self.assertEqual(install.select_data_directories(manifest, files, True), [cheats, sys_tree])
|
||||
self.assertEqual(install.select_data_directories(manifest, files, True, True), [sys_tree])
|
||||
self.assertEqual(install.select_data_directories(manifest, files, False, True), [sys_tree])
|
||||
install._check_data_directory(cheats, "data_directories[0]")
|
||||
with self.assertRaises(ValueError):
|
||||
install._check_data_directory({**cheats, "optional": False}, "data_directories[0]")
|
||||
|
||||
def test_the_archive_is_drawn_and_counted(self):
|
||||
"""82 MB of archives came with no progress line, and the run ended
|
||||
on "310.5 KB downloaded", the BIOS files alone."""
|
||||
|
||||
@@ -12912,6 +12912,18 @@ class DriveIsACardByItsFiles(unittest.TestCase):
|
||||
self.assertEqual([install.card_system(mount) for mount in mounts],
|
||||
[None, None, None])
|
||||
|
||||
def test_a_dest_on_a_games_part_reads_its_rom_folders(self):
|
||||
"""--dest EASYROMS/bios --for-games from a computer: detection never
|
||||
ran on the card, the generic RetroArch folders were tried, and the
|
||||
run kept every file of the list."""
|
||||
arkos = games_part(self.root / "media" / "EASYROMS")
|
||||
index = {"setups": {"arkos": {}, "retroarch": {}}}
|
||||
with mock.patch.object(install, "_mount_points", return_value=frozenset()), \
|
||||
mock.patch.object(install, "_drive_not_removable", return_value=False):
|
||||
self.assertEqual(install.games_setups(index, "retroarch", arkos / "bios"), ["arkos"])
|
||||
self.assertEqual(install.games_setups(index, "retroarch", self.root / "bios"),
|
||||
["retroarch"])
|
||||
|
||||
def test_a_card_beside_a_data_drive_comes_first(self):
|
||||
"""The ArkOS card and the data drive mounted,
|
||||
the data drive sorted first and took Enter. The card comes first in
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""What the site tells an AI agent: llms.txt, the ARD manifest, the WebMCP tools.
|
||||
|
||||
An agent asked to set up a handheld reads these first. They must lead it to
|
||||
the fewest files the games need, and every address they give must exist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import siteagents # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
TOOLS = REPO_ROOT / "website" / "src" / "scripts" / "agent-tools.ts"
|
||||
FOOTER = REPO_ROOT / "website" / "src" / "components" / "Footer.astro"
|
||||
CONFIG = REPO_ROOT / "website" / "astro.config.mjs"
|
||||
|
||||
|
||||
class TheGuideLeadsToTheFewestFiles(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.text = siteagents.llms_txt([("retroarch", "RetroArch"), ("batocera", "Batocera")])
|
||||
|
||||
def test_it_is_llms_txt(self) -> None:
|
||||
self.assertTrue(self.text.startswith("# RetroBIOS\n\n> "))
|
||||
self.assertIn("\n## Setting up a device for its user\n", self.text)
|
||||
|
||||
def test_every_command_installs_only_what_the_games_need(self) -> None:
|
||||
commands = re.findall(r"`((?:curl|&) [^`]+)`", self.text)
|
||||
self.assertGreaterEqual(len(commands), 3)
|
||||
for command in commands:
|
||||
with self.subTest(command=command):
|
||||
self.assertIn("--for-games --required-only", command)
|
||||
|
||||
def test_each_setup_has_its_page(self) -> None:
|
||||
self.assertIn("(https://abdess.github.io/retrobios/platforms/batocera/)", self.text)
|
||||
|
||||
@unittest.skipUnless((DIST / "llms.txt").is_file(), "website/dist is not built")
|
||||
def test_every_address_of_the_built_guide_exists(self) -> None:
|
||||
built = (DIST / "llms.txt").read_text(encoding="utf-8")
|
||||
for url in re.findall(r"\((https://abdess\.github\.io/retrobios/[^)]*)\)", built):
|
||||
with self.subTest(url=url):
|
||||
target = DIST / url.removeprefix(siteagents.SITE)
|
||||
self.assertTrue(target.is_file() or (target / "index.html").is_file(), url)
|
||||
|
||||
|
||||
class TheSkillIsAnAgentSkill(unittest.TestCase):
|
||||
"""ARD reserves text/markdown for agent skills (Lighthouse warns on a
|
||||
plain Markdown entry): the setup steps are published as one."""
|
||||
|
||||
def test_front_matter_then_instructions(self) -> None:
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
text = siteagents.skill_md()
|
||||
head, _, body = text.removeprefix("---\n").partition("\n---\n")
|
||||
front = yaml.safe_load(head)
|
||||
self.assertEqual(set(front), {"name", "description"})
|
||||
self.assertRegex(front["name"], r"^[a-z0-9-]{1,64}$")
|
||||
self.assertLessEqual(len(front["description"]), 1024)
|
||||
self.assertTrue(body.lstrip().startswith("# "))
|
||||
for command in re.findall(r"`((?:curl|&) [^`]+)`", body):
|
||||
with self.subTest(command=command):
|
||||
self.assertIn("--for-games --required-only", command)
|
||||
|
||||
def test_a_folder_is_quoted_and_a_hostile_one_asked_about(self) -> None:
|
||||
for text in (siteagents.skill_md(), siteagents.llms_txt([])):
|
||||
with self.subTest(text=text[:20]):
|
||||
self.assertIn("--dest='<card>/bios'", text)
|
||||
self.assertIn("--for-games='<folder>'", text)
|
||||
self.assertIn("starts with `-`", text)
|
||||
# The characters agent-tools.ts refuses, typographic quotes included.
|
||||
# The backtick is named in words: Markdown would read it.
|
||||
self.assertIn("a backtick", siteagents.REFUSED)
|
||||
for char in "'\"‘’‚‛“”„":
|
||||
self.assertIn(char, siteagents.REFUSED)
|
||||
self.assertIn(char, TOOLS.read_text(encoding="utf-8"))
|
||||
self.assertIn(siteagents.REFUSED, text)
|
||||
|
||||
def test_the_manifest_types_it_as_a_skill(self) -> None:
|
||||
skill = siteagents.ard_manifest()["entries"][0]
|
||||
self.assertEqual(skill["type"], 'text/markdown; profile="urn:air:agent-skills"')
|
||||
self.assertTrue(skill["url"].endswith(siteagents.SKILL_PATH))
|
||||
|
||||
|
||||
class TheManifestIsAnArdManifest(unittest.TestCase):
|
||||
"""The terms ard-entry.schema.json requires (ards-project/ard-spec)."""
|
||||
|
||||
def test_entries(self) -> None:
|
||||
entries = siteagents.ard_manifest()["entries"]
|
||||
self.assertTrue(entries)
|
||||
for entry in entries:
|
||||
with self.subTest(entry=entry.get("identifier")):
|
||||
self.assertRegex(entry["identifier"], r"^urn:air:[a-zA-Z0-9.-]+(:[a-zA-Z0-9._-]+)+$")
|
||||
self.assertTrue(entry["displayName"])
|
||||
self.assertRegex(entry["type"], r'^[a-z]+/[\w.+-]+(; [a-z]+="[^"]+")?$')
|
||||
self.assertTrue(("url" in entry) != ("data" in entry))
|
||||
self.assertTrue(2 <= len(entry["representativeQueries"]) <= 5)
|
||||
self.assertTrue(entry["url"].startswith(siteagents.SITE))
|
||||
|
||||
def test_every_page_links_it(self) -> None:
|
||||
self.assertIn("rel: 'ard'", CONFIG.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TheToolsReadOnly(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.source = TOOLS.read_text(encoding="utf-8")
|
||||
|
||||
def test_every_page_registers_them(self) -> None:
|
||||
self.assertIn("import '../scripts/agent-tools.ts';", FOOTER.read_text(encoding="utf-8"))
|
||||
names = re.findall(r"^\s*name: '([a-z_]+)'", self.source, re.M)
|
||||
self.assertEqual(names, ["lookup_bios_hash", "emulator_bios_files", "search_retrobios",
|
||||
"device_setup_command"])
|
||||
self.assertEqual(self.source.count("readOnlyHint: true"), len(names))
|
||||
|
||||
def test_the_setup_command_defaults_to_the_games(self) -> None:
|
||||
self.assertIn("const games = everything ? [] :", self.source)
|
||||
self.assertIn("'--required-only'", self.source)
|
||||
|
||||
def test_a_value_that_could_escape_the_command_is_refused(self) -> None:
|
||||
"""The command is pasted into a shell: PowerShell also ends a single-quoted
|
||||
string on the typographic quotes, and a path starting with "-" reads as
|
||||
an option."""
|
||||
self.assertIn("const unsafe = /[\\u0000-\\u001f\\u007f'\"`‘’‚‛“”„]/;", self.source)
|
||||
self.assertIn("value.startsWith('-')", self.source)
|
||||
self.assertIn("/^[a-z0-9_-]+$/.test(platform)", self.source)
|
||||
self.assertIn("`--dest=${quote(bios_folder as string)}`", self.source)
|
||||
|
||||
def test_they_reach_no_other_site(self) -> None:
|
||||
code = re.sub(r"^\s*//.*$", "", self.source, flags=re.M)
|
||||
self.assertEqual(set(re.findall(r"https://[\w.-]+", code)), {"https://abdess.github.io"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,6 +77,9 @@ export default defineConfig({
|
||||
// Ownership of the URL-prefix property https://abdess.github.io/retrobios/
|
||||
// in Google Search Console. Removing it unverifies the property.
|
||||
{ tag: 'meta', attrs: { name: 'google-site-verification', content: '8SueST3hNHl8_vM-OJJP0rz-ZMlI2DALLpChrwZoGMs' } },
|
||||
// What an AI agent reads first (scripts/siteagents.py): the ARD manifest lists
|
||||
// llms.txt and the JSON API.
|
||||
{ tag: 'link', attrs: { rel: 'ard', type: 'application/json', href: `${BASE}ard.json` } },
|
||||
],
|
||||
social: [{ icon: 'github', label: 'RetroBIOS on GitHub', href: 'https://github.com/Abdess/retrobios' }],
|
||||
customCss: ['./src/styles/retrobios.css'],
|
||||
|
||||
@@ -48,6 +48,7 @@ const date = releaseDate(tag);
|
||||
|
||||
<script>
|
||||
import '../scripts/count.ts';
|
||||
import '../scripts/agent-tools.ts';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// WebMCP tools (https://developer.chrome.com/docs/ai/webmcp): what an AI
|
||||
// agent browsing the site can ask it directly, instead of reading pages.
|
||||
// Each tool reads the JSON the site already publishes (api/v1/) or its
|
||||
// search index, changes nothing and sends nothing anywhere else. A browser
|
||||
// without WebMCP skips the whole file.
|
||||
|
||||
type ToolResult = string;
|
||||
type Tool = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
annotations?: { readOnlyHint?: boolean };
|
||||
execute: (input: Record<string, unknown>) => Promise<ToolResult>;
|
||||
};
|
||||
type ModelContext = { registerTool: (tool: Tool) => Promise<void> | void };
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const SITE = new URL(BASE, location.origin);
|
||||
const json = async <T>(path: string): Promise<T> => {
|
||||
const response = await fetch(new URL(path, SITE));
|
||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
const absolute = (url: string) => new URL(url, location.origin).href;
|
||||
|
||||
type LookupItem = { algorithm: string; layer: string; name?: string; value: string; url: string; owner?: string; sha1?: string };
|
||||
type ProfileFile = Record<string, unknown> & { name?: string; required?: boolean };
|
||||
type EmulatorDoc = { items: { id: string; profile: { emulator?: string; systems?: string[]; files?: ProfileFile[] } }[] };
|
||||
type CoreIndex = { items: { name: string; profile: string; preferred?: boolean }[] };
|
||||
|
||||
const HASH = /^(?:[0-9a-f]{8}|[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
|
||||
const lookupHash: Tool = {
|
||||
name: 'lookup_bios_hash',
|
||||
description:
|
||||
'Find which BIOS or firmware file a hash designates in the RetroBIOS collection, and the emulators or setups that '
|
||||
+ 'declare it. Accepts a CRC32 (8 hex characters), MD5 (32), SHA-1 (40) or SHA-256 (64).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { hash: { type: 'string', description: 'The hash, in hexadecimal.' } },
|
||||
required: ['hash'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ hash }) {
|
||||
const value = String(hash ?? '').trim().toLowerCase().replace(/^0x/, '');
|
||||
if (!HASH.test(value)) return 'Not a CRC32, MD5, SHA-1 or SHA-256 value.';
|
||||
const shard = await json<{ items: LookupItem[] }>(`api/v1/lookup/${value.slice(0, 2)}.json`);
|
||||
const found = shard.items.filter((item) => item.value === value);
|
||||
if (!found.length) return `No file in the collection or its profiles has the ${value.length === 8 ? 'CRC32' : 'hash'} ${value}.`;
|
||||
return JSON.stringify(found.map((item) => ({ ...item, url: absolute(item.url) })), null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const FILE_FIELDS = ['name', 'path', 'required', 'system', 'size', 'md5', 'sha1', 'crc32', 'sha256', 'region', 'note'];
|
||||
|
||||
const emulatorFiles: Tool = {
|
||||
name: 'emulator_bios_files',
|
||||
description:
|
||||
'List the BIOS and firmware files an emulator or libretro core reads, from its profile checked against its source code: '
|
||||
+ 'each file name, where it goes, whether the emulator starts without it, and its hashes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
emulator: { type: 'string', description: 'The emulator or core name, for example "mGBA", "beetle_psx" or "DuckStation".' },
|
||||
},
|
||||
required: ['emulator'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ emulator }) {
|
||||
const asked = String(emulator ?? '').trim();
|
||||
const key = asked.toLowerCase().replace(/[\s_]+/g, '_');
|
||||
const cores = await json<CoreIndex>('api/v1/cores.json');
|
||||
const named = cores.items.filter((core) => [core.name.toLowerCase(), core.profile].includes(key)
|
||||
|| core.name.toLowerCase().replace(/[\s-]+/g, '_') === key);
|
||||
const id = (named.find((core) => core.preferred) ?? named[0])?.profile ?? key.replace(/_/g, '-');
|
||||
let doc: EmulatorDoc;
|
||||
try {
|
||||
doc = await json<EmulatorDoc>(`api/v1/emulators/${encodeURIComponent(id)}.json`);
|
||||
} catch {
|
||||
return `No emulator profile named "${asked}". The list is at ${absolute(`${BASE}emulators/`)}.`;
|
||||
}
|
||||
const { profile } = doc.items[0];
|
||||
const files = (profile.files ?? []).map((file) =>
|
||||
Object.fromEntries(FILE_FIELDS.filter((field) => file[field] !== undefined).map((field) => [field, file[field]])));
|
||||
return JSON.stringify({
|
||||
emulator: profile.emulator ?? id,
|
||||
page: absolute(`${BASE}emulators/${id}/`),
|
||||
systems: profile.systems ?? [],
|
||||
files,
|
||||
}, null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
type Pagefind = { search: (query: string) => Promise<{ results: { data: () => Promise<{ url: string; meta: { title?: string }; excerpt: string }> }[] }> };
|
||||
|
||||
const searchSite: Tool = {
|
||||
name: 'search_retrobios',
|
||||
description: 'Search the RetroBIOS site: emulators, consoles, setups (RetroArch, Batocera, EmuDeck...) and guides. Returns the best pages.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string', description: 'Words to search for, for example "dreamcast bios batocera".' } },
|
||||
required: ['query'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ query }) {
|
||||
const words = String(query ?? '').trim();
|
||||
if (!words) return 'Nothing to search for.';
|
||||
const pagefind = (await import(/* @vite-ignore */ `${BASE}pagefind/pagefind.js`)) as Pagefind;
|
||||
const { results } = await pagefind.search(words);
|
||||
const top = await Promise.all(results.slice(0, 8).map((result) => result.data()));
|
||||
if (!top.length) return `No page matches "${words}".`;
|
||||
return JSON.stringify(top.map((page) => ({
|
||||
title: page.meta.title ?? '',
|
||||
url: absolute(page.url),
|
||||
excerpt: page.excerpt.replace(/<[^>]+>/g, ''),
|
||||
})), null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
// The command an agent runs to set up its user's device, from the same
|
||||
// options wiki/installer.md documents: the fewest files the games on the
|
||||
// card need, unless the user asks for everything.
|
||||
const SH = 'curl -fsSL https://abdess.github.io/retrobios/i | sh -s --';
|
||||
const PS = '& ([scriptblock]::Create((irm https://abdess.github.io/retrobios/i.ps1)))';
|
||||
|
||||
const setupCommand: Tool = {
|
||||
name: 'device_setup_command',
|
||||
description:
|
||||
'The one command that installs the BIOS files a device or computer needs, with RetroBIOS\'s installer. By default it '
|
||||
+ 'installs only what the games on the card need to start, which is what most players want. It finds the setup '
|
||||
+ '(RetroArch, Batocera, ArkOS, dArkOS, muOS, KNULLI, ROCKNIX...) and its BIOS folder itself.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
shell: { type: 'string', enum: ['sh', 'powershell'], description: '"sh" on Linux, macOS and handhelds (the default), "powershell" on Windows.' },
|
||||
everything: { type: 'boolean', description: 'Install every file of the setup\'s list instead of only what the games need. Gigabytes.' },
|
||||
bios_folder: { type: 'string', description: 'The BIOS folder, when run on a computer and the card is not found, for example "/media/user/EASYROMS/bios".' },
|
||||
games_folder: { type: 'string', description: 'A games folder that is not on the card, one subfolder per system.' },
|
||||
platform: { type: 'string', description: 'The setup, only when it is not detected (names from the installer\'s --list-platforms).' },
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ shell, everything, bios_folder, games_folder, platform }) {
|
||||
const powershell = shell === 'powershell';
|
||||
// The command is pasted into a shell: a value that could close its
|
||||
// quotes (PowerShell also ends a string on the typographic ones) or
|
||||
// start another option is refused, never escaped.
|
||||
const unsafe = /[\u0000-\u001f\u007f'"`‘’‚‛“”„]/;
|
||||
for (const [field, value] of [['bios_folder', bios_folder], ['games_folder', games_folder]] as const) {
|
||||
if (value !== undefined && (typeof value !== 'string' || !value || unsafe.test(value) || value.startsWith('-'))) {
|
||||
return `${field} must be a folder path without quotes or control characters.`;
|
||||
}
|
||||
}
|
||||
if (platform !== undefined && (typeof platform !== 'string' || !/^[a-z0-9_-]+$/.test(platform))) {
|
||||
return 'platform must be a setup name such as retroarch or batocera.';
|
||||
}
|
||||
const run = powershell ? PS : SH;
|
||||
const quote = (value: string) => `'${value}'`;
|
||||
const place: string[] = [];
|
||||
if (platform) place.push(`--platform=${platform}`);
|
||||
if (bios_folder) place.push(`--dest=${quote(bios_folder as string)}`);
|
||||
const games = everything ? [] : [games_folder ? `--for-games=${quote(games_folder as string)}` : '--for-games', '--required-only'];
|
||||
const command = (...more: string[]) => [run, ...place, ...more].join(' ');
|
||||
return JSON.stringify({
|
||||
preview: command(...games, '--dry-run'),
|
||||
install: command(...games, '--yes'),
|
||||
check_later: command('--check'),
|
||||
notes: [
|
||||
'Run the preview first: it writes nothing and lists what would be downloaded.',
|
||||
'Run on the device itself, or on a computer with the device\'s card plugged in.',
|
||||
'A game the installer cannot identify gets every BIOS of its system, and the run names it.',
|
||||
`Guide: ${absolute(`${BASE}llms.txt`)}`,
|
||||
],
|
||||
}, null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const context = (document as Document & { modelContext?: ModelContext }).modelContext;
|
||||
if (context?.registerTool) {
|
||||
for (const tool of [setupCommand, lookupHash, emulatorFiles, searchSite]) {
|
||||
Promise.resolve(context.registerTool(tool)).catch((error) => console.warn(`WebMCP: ${tool.name}:`, error));
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -405,7 +405,7 @@ lists record these files as omitted.
|
||||
|
||||
<!-- generated: dry-run -->
|
||||
|
||||
A RetroArch dry run into an empty folder, with the list of 2026-11-05, printed this:
|
||||
A RetroArch dry run into an empty folder, with the list of 2026-11-06, printed this:
|
||||
|
||||
```
|
||||
Fetching file index for retroarch...
|
||||
@@ -435,7 +435,7 @@ the installer can fetch them.
|
||||
<!-- generated: run-date -->
|
||||
|
||||
Each line a first install prints, and what it means. The run below is a
|
||||
Batocera install into an empty folder, from the list of 2026-11-05.
|
||||
Batocera install into an empty folder, from the list of 2026-11-06.
|
||||
The figures change with each release.
|
||||
<!-- /generated: run-date -->
|
||||
|
||||
@@ -531,7 +531,7 @@ would save, once that saving is over half.
|
||||
|
||||
<!-- generated: bulk-hint -->
|
||||
|
||||
RetroArch with `--system sony-playstation`, from the list of 2026-11-05:
|
||||
RetroArch with `--system sony-playstation`, from the list of 2026-11-06:
|
||||
|
||||
```
|
||||
duckstation pulls 316.5 MB of this 329.0 MB download for sony-playstation. --core pcsx1 keeps 11.0 MB of it and saves 318.0 MB (96%).
|
||||
@@ -876,7 +876,9 @@ BIOS sets of its parent chain (`neogeo.zip` for `mslug.zip`).
|
||||
|
||||
A game that is not recognised gets every BIOS of its system, and the run names
|
||||
it. So does an arcade set the core does not know. **Only what starts**
|
||||
(`--required-only`) leaves out the files the cores mark optional.
|
||||
(`--required-only`) leaves out the files the cores mark optional, and the data
|
||||
folders every core reading them runs without (FinalBurn Neo's cheats and
|
||||
samples).
|
||||
|
||||
The games are read without hashing whole discs, and a cache keeps what was
|
||||
read, so a second run over the same card reads only new files. It is a
|
||||
|
||||
@@ -69,7 +69,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
|
||||
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
Reference in new issue
Block a user