From f568c79e19167f438146e5583eca4b40cf7a2eb1 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:39:28 +0200 Subject: [PATCH] feat: emulator pages for any-name bios apps An app found by content says to get one BIOS of any name and where it goes, with no install command it cannot carry out. Every arcade core names its romset, part pages name their parent and neighbours, a required file says which system it serves, and the core template leaves the index. --- scripts/generate_site.py | 181 ++++++++++++++++++++++++++++---- tests/test_site_r3_emulators.py | 7 +- tests/test_site_r4_emulators.py | 171 ++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 tests/test_site_r4_emulators.py diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 446e91a3..b9f511c3 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -5642,9 +5642,18 @@ def emulator_initial(display: str) -> str: return initial if "A" <= initial <= "Z" else INITIAL_DIGITS +# Profiles that document a format, not an emulator: `00_example` is the +# template libretro-core-info ships for core authors ("No loadable binary, +# no emulated system", its notes). Out of the index and the search, like a +# test core, and its page stays for the links that cite it. +TEMPLATE_PROFILES = frozenset({"00_example"}) +_TEMPLATE_FRONT = ["---", "search: exclude", "---", ""] + + def generate_emulators_index(profiles: dict) -> str: unique = { - k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") + k: v for k, v in profiles.items() + if v.get("type") not in ("alias", "test") and k not in TEMPLATE_PROFILES } aliases = {k: v for k, v in profiles.items() if v.get("type") == "alias"} @@ -5753,12 +5762,13 @@ def comes_with_app(f: dict) -> bool: return f.get("bundled") is True and not f.get("mode") -def _file_badges(f: dict, in_repo: bool) -> list[str]: +def _file_badges(f: dict, in_repo: bool, for_system: str = "") -> list[str]: """The status chips shown beside a file name. Fourteen independent optional fields, each contributing at most one chip. Kept apart from the rest of the row so the field-by-field - rendering stays readable. + rendering stays readable. ``for_system`` names the system a required + file serves, on a profile that runs more than one. """ required = f.get("required", False) hle = f.get("hle_fallback", False) @@ -5780,8 +5790,9 @@ def _file_badges(f: dict, in_repo: bool) -> list[str]: 'comes with the app' ) elif required: + needed = f"required for {html.escape(for_system)}" if for_system else "required" badges.append( - 'required' + f'{needed}' ) else: badges.append( @@ -5981,12 +5992,22 @@ def _render_emulator_file( size_note = f.get("size_note", "") size_range = f.get("size_range", "") - badges = _file_badges(f, in_repo) + # melonDS reads DS and DSi files: "required" alone sat on the DSi NAND + # above the DS files, for an emulator most people run for DS games. + for_system = (system_name(str(fsystem)) + if fsystem and len(profile.get("systems") or []) > 1 else "") + badges = _file_badges(f, in_repo, for_system) badge_str = " ".join(badges) lines.append(markup.block_open("div", 'class="rb-file-entry"')) lines.append("") marker = f'' if alias else "" - lines.append(_card_title(f"{marker}{_code(fname)} {badge_str}", anchor, weight=5)) + label = _code(fname) + if f is any_name_bios(profile): + # Found by content: the name is an example, any image of the system works. + system = system_name((profile.get("systems") or [""])[0]) + label = (f"a {system} BIOS (example: {_code(fname)})" if _named_example(str(fname)) + else f"a {system} BIOS, any file name") + lines.append(_card_title(f"{marker}{label} {badge_str}", anchor, weight=5)) lines.append("") if desc: lines.extend([_escape_tags(str(desc)), ""]) @@ -6303,6 +6324,12 @@ def _install_block(name: str, profile: dict) -> list[str]: """ if profile.get("type") in ("alias", "launcher", "test"): return [] + if profile.get("type") == "standalone" and any_name_bios(profile) is not None: + # `--emulator` installs into a platform's BIOS folder and turns off + # the pass that fills standalone emulator folders (install.py, + # `narrowing` in main): an app on its own reported "No supported + # platform detected". The download step of the intro replaces it. + return [] return search_excluded(markup.details("tip", "Install this emulator's files", [ "The installer finds your platform and its BIOS folder, then downloads " "only the files this emulator reads there and checks each one.", @@ -6654,9 +6681,15 @@ def _data_tree_page(ref: str, entry: dict, pages: list[tuple[str, list[str]]], owner = max(readers, default=None, key=lambda reader: len(reader[0]) if ref.startswith(f"{reader[0]}-") else 0) if owner: - front["parent"] = {"label": owner[1], "link": f"{owner[0]}.md"} + head = _part_front_matter( + (owner[1], f"{owner[0]}.md"), + [(f"The {ref} data directory, part {n}", f"{other}.md") + for n, (other, _names) in enumerate(pages, 1)], index, front) + else: + head = ["---", yaml.safe_dump(front, sort_keys=False, allow_unicode=True).rstrip("\n"), + "---", ""] lines = [ - "---", yaml.safe_dump(front, sort_keys=False, allow_unicode=True).rstrip("\n"), "---", "", + *head, f"# The {ref} data directory{part}", "", f"{entry.get('description') or 'A data directory'}: " f"{_count(len(members), 'file')}, as the packs ship them." @@ -6748,7 +6781,8 @@ def _hash_short(f: dict) -> str: return "" -def _summary_table(files: list[dict], indexes: list[int], cell: Callable[[int, str], str]) -> list[str]: +def _summary_table(files: list[dict], indexes: list[int], cell: Callable[[int, str], str], + multi_system: bool = False) -> list[str]: """The table of an emulator's entries, one row each, ``cell`` writing the linked name of entry i from its shown path. @@ -6762,6 +6796,8 @@ def _summary_table(files: list[dict], indexes: list[int], cell: Callable[[int, s for i in indexes: f = files[i] needed = "with the app" if comes_with_app(f) else "yes" if f.get("required") else "no" + if needed == "yes" and multi_system and f.get("system"): + needed = f"for {system_name(str(f['system']))}" rows.append((cell(i, str(f.get("path") or f.get("name", ""))), _region_words(f.get("region"), 3), needed, _hash_short(f))) keep = [0] + [column for column in (1, 3) if any(row[column] for row in rows)] @@ -6806,12 +6842,90 @@ def generate_emulator_page( name, profile, db, consumers, data_names, data_registry, data_dirs)[name] -def _emulator_intro(emu_name: str, profile: dict) -> str: +def any_name_bios(profile: dict) -> dict | None: + """The one BIOS an emulator needs under any name, or None. + + A profile found by content (`found_by: content`, `bios_mode: agnostic`) + for one system, with one required file of no declared region: the + code identifies the image by what it holds, so the entry's name is an + example. AetherSX2's page showed `bios/ps2-0230a-20080220.bin` as the + file to get, while its notes say no name and no hash is matched. + """ + if profile.get("found_by") != "content" and profile.get("bios_mode") != "agnostic": + return None + if len(profile.get("systems") or []) != 1: + return None + required = [f for f in profile.get("files") or [] + if isinstance(f, dict) and f.get("required") is True and not comes_with_app(f)] + if len(required) != 1 or required[0].get("region") or required[0].get("category") not in (None, "bios"): + return None + return required[0] + + +def _collection_card_href(entry: dict, consumers: FileConsumers | None, owner: str) -> str: + """The collection card of the copy that answers an emulator entry, or "".""" + if consumers is None: + return "" + sha1 = consumers.sha1_of(entry, entry.get("path") or "", owner=owner) + held = consumers.path_of(sha1) if sha1 else "" + if not held.startswith("bios/"): + return "" + return f"../systems/{urllib.parse.quote(card_page(held, sha1))}.md#sha1-{sha1[:12]}" + + +def _named_example(name: str) -> bool: + """A real file name, not a placeholder such as `.bin`.""" + return bool(name) and "<" not in name + + +def _any_name_folder(profile: dict, entry: dict) -> str: + """Where the one BIOS goes, as the profile states it, or "".""" + folder = str(profile.get("bios_directory") or "") + if not folder: + path = str(entry.get("path") or "") + folder = path.rsplit("/", 1)[0] + "/" if "/" in path else "" + if not folder: + return "" + if profile.get("type") == "standalone": + # A bare `bios/` is a folder inside the app's own data folder. + return f"the {_code(folder.strip('/'))} folder inside the app's own data folder" + return f"{_code(folder)}, inside your BIOS folder" + + +def _any_name_steps(emu_name: str, profile: dict, entry: dict, href: str) -> str: + """The intro of an emulator that takes one BIOS of any name: get one, + put it in its folder, any name works.""" + system = system_name((profile.get("systems") or [""])[0]) + name = str(entry.get("name") or "") + if _named_example(name) and href: + get = f"Download [{_code(name)}]({href})." + else: + get = f"Get one from the {_named_system_link(profile['systems'][0], '../')} files." + steps = [get] + folder = _any_name_folder(profile, entry) + if folder: + steps.append(f"Put it in {folder}.") + reader = "The app" if profile.get("type") == "standalone" else "The emulator" + return "\n".join([ + f"{emu_name} needs one {system} BIOS file, any region.", + "", + *(f"{number}. {step}" for number, step in enumerate(steps, 1)), + "", + f"Any file name works. {reader} reads what the file holds.", + ]) + + +def _emulator_intro(emu_name: str, profile: dict, href: str = "") -> str: """What the emulator needs, in plain words, from fields every profile has. The page opened on a twelve-row table: "Type", "Version", "Checked by". DuckStation needs one PlayStation BIOS of any name, and the reader had - to infer it from the "Files found by" row.""" + to infer it from the "Files found by" row. ``href`` links the + collection's copy of the one BIOS an emulator takes under any name. + """ + entry = any_name_bios(profile) + if entry is not None: + return _any_name_steps(emu_name, profile, entry, href) names: list[str] = [] for sys_id in profile.get("systems") or []: label = system_name(sys_id) @@ -6917,17 +7031,23 @@ def emulator_pages( data_dirs = profile.get("data_directories", []) part_pages: dict[str, str] = {} - intro = _emulator_intro(emu_name, profile) + one_bios = any_name_bios(profile) + intro = _emulator_intro(emu_name, profile, _collection_card_href( + one_bios, consumers, name) if one_bios else "") romset = emulator_romset(profile) lines = [ + *(_TEMPLATE_FRONT if name in TEMPLATE_PROFILES else []), f"# {emu_name}", "", *([intro, ""] if intro else []), ] - if romset and any(isinstance(f, dict) and f.get("category") == "bios_zip" for f in files): + if romset: # The commonest arcade failure is a set from another MAME version, # and its answer sat on a Reference page no arcade page linked. - lines.extend([f"Its BIOS ZIPs must come from the {romset} romset. " + # FinalBurn profiles list the members of their archives, not the + # archives, and MAME 2003-Plus none at all: the line went missing + # on both. + lines.extend([f"Its BIOS and game sets must come from the {romset} romset. " f"[Which version each core reads]({ROMSET_FAQ}).", ""]) card_start = len(lines) lines += [ @@ -7042,7 +7162,8 @@ def emulator_pages( indexed = len(files) >= EMULATOR_SUMMARY_MIN if indexed: lines.extend(search_excluded(_summary_table( - files, order, lambda i, shown: f"[{_code(shown)}](#{anchors[i]})"))) + files, order, lambda i, shown: f"[{_code(shown)}](#{anchors[i]})", + len(systems or []) > 1))) lines.append("") lines.extend(held) # Below the table, which indexes them, the cards past the @@ -7052,7 +7173,8 @@ def emulator_pages( files, order, lambda group: [line for i in group for line in cards[i]], fold=indexed)) else: - lines.extend(_card_index(emu_name, files, anchors, aliases, parts)) + lines.extend(_card_index(emu_name, files, anchors, aliases, parts, + len(systems or []) > 1)) lines.extend(held) for number, (stem, entries) in enumerate(parts, 1): part_pages[stem] = _emulator_part_page( @@ -7124,7 +7246,7 @@ def emulator_pages( def _card_index( emu_name: str, files: list[dict], anchors: list[str], aliases: list[str], - parts: list[tuple[str, range]], + parts: list[tuple[str, range]], multi_system: bool = False, ) -> list[str]: """Every entry of a split emulator page, one row each, linked to its card. @@ -7144,7 +7266,7 @@ def _card_index( def table(group: list[int]) -> list[str]: return [ markup.block_open("section", f'class="rb-card-index" {markup.search_exclude()}'), - "", *_summary_table(files, group, cell), "", "", "", + "", *_summary_table(files, group, cell, multi_system), "", "", "", ] return [ @@ -7157,6 +7279,26 @@ def _card_index( ] +def _part_front_matter(parent: tuple[str, str], siblings: list[tuple[str, str]], + index: int, front: dict | None = None) -> list[str]: + """The front matter of part ``index`` of ``siblings`` ((label, link) + each): the page it belongs to, its neighbours and the part weight. + + MAME's eight card pages had no parent, so the crumbs skipped MAME, and + `prev: false`, so part 5 reached part 6 only through a sentence. + The first part's previous page is the parent, the last part has no next. + """ + data = dict(front or {}) + data["parent"] = {"label": parent[0], "link": parent[1]} + before = siblings[index - 1] if index > 0 else parent + data["prev"] = {"label": before[0], "link": before[1]} + data["next"] = ({"label": siblings[index + 1][0], "link": siblings[index + 1][1]} + if index + 1 < len(siblings) else False) + data["searchWeight"] = SEARCH_WEIGHT.get("part", DEMOTED_WEIGHT) + return ["---", yaml.safe_dump(data, sort_keys=False, allow_unicode=True).rstrip("\n"), + "---", ""] + + def _emulator_part_page( name: str, emu_name: str, number: int, parts: list[tuple[str, range]], cards: list[list[str]], files: list[dict], @@ -7167,6 +7309,9 @@ def _emulator_part_page( others = ", ".join( f"[{n}]({stem}.md)" for n, (stem, _e) in enumerate(parts, 1) if n != number) lines = [ + *_part_front_matter((emu_name, f"{name}.md"), + [(f"{emu_name} files, part {n}", f"{stem}.md") + for n, (stem, _e) in enumerate(parts, 1)], number - 1), f"# {emu_name} files, part {number} of {len(parts)}", "", f"Part of [{emu_name}]({name}.md), whose page has the profile, the install " diff --git a/tests/test_site_r3_emulators.py b/tests/test_site_r3_emulators.py index fb28497a..7424cb2d 100644 --- a/tests/test_site_r3_emulators.py +++ b/tests/test_site_r3_emulators.py @@ -56,7 +56,7 @@ class EachArcadePageNamesItsRomset(unittest.TestCase): page = _page("mame2003") self.assertIn(f"| Romset | [MAME 0.78]({gs.ROMSET_FAQ}) |", page) self.assertNotIn("| MAME version |", _page("mame2016")) - line = page.index("Its BIOS ZIPs must come from the MAME 0.78 romset.") + line = page.index("Its BIOS and game sets must come from the MAME 0.78 romset.") self.assertLess(line, page.index("About this profile")) self.assertIn(f"[Which version each core reads]({gs.ROMSET_FAQ})", page) @@ -65,7 +65,7 @@ class EachArcadePageNamesItsRomset(unittest.TestCase): self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq) def test_no_line_without_bios_zips(self): - self.assertNotIn("Its BIOS ZIPs", _page("duckstation")) + self.assertNotIn("must come from the", _page("duckstation")) class WhatTheAppShipsIsNoFileToGet(unittest.TestCase): @@ -91,7 +91,8 @@ class WhatTheAppShipsIsNoFileToGet(unittest.TestCase): summary = self.page[self.page.index("## Files"):] self.assertIn(f"{shipped} more files come with the app.", summary) self.assertIn(": 1 required,", summary) - self.assertIn("It reads 8 files, 1 of them required", self.page) + # Found by content: one BIOS of any name, said as steps (round 4). + self.assertIn("NetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", self.page) def test_their_own_section_after_the_others(self): heads = re.findall(r"^## (.+)$", self.page, re.M) diff --git a/tests/test_site_r4_emulators.py b/tests/test_site_r4_emulators.py new file mode 100644 index 00000000..6ed8da34 --- /dev/null +++ b/tests/test_site_r4_emulators.py @@ -0,0 +1,171 @@ +"""Emulator pages, round 4 (package 7). + +AetherSX2's page offered a command that answered "No supported platform +detected" and a file to get by one name, while the app reads any PS2 BIOS +by its contents. FinalBurn Neo and MAME 2003-Plus named no romset under +their intro. MAME's card pages and the data directory pages did not say +where they sit. melonDS marked its DSi files "required" above its DS files. +The `00_example` template was listed as an emulator. +""" + +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_site as gs # noqa: E402 + +EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}} +DIST = REPO_ROOT / "website" / "dist" + + +def _profile(name: str) -> dict: + return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8")) + + +def _pages(name: str, profile: dict | None = None) -> dict[str, str]: + return gs.emulator_pages(name, profile or _profile(name), EMPTY_DB, None, set()) + + +def _front(page: str) -> dict: + assert page.startswith("---\n"), page[:80] + return yaml.safe_load(page[4:page.index("\n---\n", 4)]) + + +class AnAppThatReadsAnyBios(unittest.TestCase): + def setUp(self) -> None: + self.page = _pages("aethersx2")["aethersx2"] + + def test_the_intro_says_what_to_do(self): + intro = self.page[self.page.index("# AetherSX2"):self.page.index("About this profile")] + self.assertIn("AetherSX2 needs one Sony PlayStation 2 BIOS file, any region.", intro) + self.assertRegex(intro, r"\n1\. .+\n2\. Put it in the `bios` folder inside the app's own data folder\.") + self.assertIn("Any file name works. The app reads what the file holds.", intro) + self.assertNotIn("It reads", intro) + + def test_the_card_names_an_example(self): + self.assertIn("a Sony PlayStation 2 BIOS (example: ps2-0230a-20080220.bin)", + self.page) + + def test_no_command_that_cannot_place_the_file(self): + self.assertNotIn("--emulator aethersx2", self.page) + self.assertNotIn("--emulator nethersx2", _pages("nethersx2")["nethersx2"]) + + def test_a_core_keeps_its_command(self): + # LRPS2 runs in RetroArch, whose BIOS folder the installer finds. + page = _pages("lrps2")["lrps2"] + self.assertIn("--emulator lrps2", page) + self.assertIn("2. Put it in `pcsx2/bios/`, inside your BIOS folder.", page) + self.assertIn("a Sony PlayStation 2 BIOS, any file name", page) + + def test_only_found_by_content_with_one_bios(self): + # DuckStation picks the BIOS of the game's region: its intro stays. + self.assertIsNone(gs.any_name_bios(_profile("duckstation"))) + self.assertIsNone(gs.any_name_bios(_profile("melonds"))) + self.assertIsNotNone(gs.any_name_bios(_profile("pcsx2"))) + + def test_the_download_links_the_collection_card(self): + steps = gs._any_name_steps("X", _profile("aethersx2"), + gs.any_name_bios(_profile("aethersx2")), + "../systems/sony.md#sha1-abc") + self.assertIn("1. Download [`ps2-0230a-20080220.bin`](../systems/sony.md#sha1-abc).", steps) + + +class EveryArcadeCoreNamesItsRomset(unittest.TestCase): + def test_finalburn_and_mame_2003_plus(self): + for name, romset in (("fbneo", "FinalBurn Neo v1.0.0.2"), + ("mame2003_plus", "MAME 0.78 (plus backports)")): + page = _pages(name)[name] + line = page.index(f"Its BIOS and game sets must come from the {romset} romset.") + self.assertLess(line, page.index("About this profile"), name) + + +class PartsKnowWhereTheySit(unittest.TestCase): + def test_emulator_card_parts(self): + pages = _pages("mame") + parts = sorted((stem for stem in pages if stem.startswith("mame-files-")), + key=lambda stem: int(stem.rsplit("-", 1)[1])) + self.assertGreater(len(parts), 2) + for index, stem in enumerate(parts): + front = _front(pages[stem]) + self.assertEqual(front["parent"], {"label": "MAME", "link": "mame.md"}) + self.assertEqual(front["searchWeight"], 0.5) + self.assertEqual(front["prev"]["link"], + f"{parts[index - 1]}.md" if index else "mame.md") + if index + 1 < len(parts): + self.assertEqual(front["next"]["link"], f"{parts[index + 1]}.md") + else: + self.assertIs(front["next"], False) + + def test_data_directory_parts(self): + ref = "x-sys" + members = {f"f{i:05d}": {"size": 1, "sha1": f"{i:040x}"} + for i in range(gs.DATA_TREE_ROWS_PER_PAGE * 2 + 1)} + gs._DATA_MEMBERS[ref] = members + gs._DATA_READERS[ref] = [("x", "X")] + try: + pages = gs.data_tree_pages(ref, members) + self.assertEqual(len(pages), 3) + middle = _front(gs._data_tree_page(ref, {}, pages, 1)) + finally: + del gs._DATA_MEMBERS[ref], gs._DATA_READERS[ref] + self.assertEqual(middle["parent"], {"label": "X", "link": "x.md"}) + self.assertEqual(middle["prev"]["link"], f"{pages[0][0]}.md") + self.assertEqual(middle["next"]["link"], f"{pages[2][0]}.md") + self.assertEqual(middle["search"], "exclude") + + +class ARequiredFileSaysForWhichSystem(unittest.TestCase): + def test_melonds_dsi_files(self): + page = _pages("melonds")["melonds"] + card = page[page.index('id="file-dsi-nand-bin"'):] + card = card[:card.index("")] + self.assertIn(">required for Nintendo DSi", card) + self.assertNotIn(">required", page) + + def test_one_system_says_required(self): + page = _pages("aethersx2")["aethersx2"] + self.assertIn(">required", page) + self.assertNotIn("required for", page) + + +class TheTemplateIsNoEmulator(unittest.TestCase): + def test_off_the_index_and_the_search(self): + profiles = {"00_example": _profile("00_example"), "melonds": _profile("melonds")} + index = gs.generate_emulators_index(profiles) + self.assertNotIn("00_example", index) + self.assertIn("melonds.md", index) + self.assertEqual(_front(_pages("00_example")["00_example"])["search"], "exclude") + + +@unittest.skipUnless((DIST / "emulators" / "aethersx2" / "index.html").is_file(), "site not built") +class TheBuiltPages(unittest.TestCase): + def _html(self, path: str) -> str: + return (DIST / path / "index.html").read_text(encoding="utf-8") + + def test_aethersx2_downloads_from_the_collection(self): + page = self._html("emulators/aethersx2") + self.assertRegex(page, r'Download ]*rb-breadcrumbs.*?', page, re.S) + self.assertIsNotNone(crumbs) + self.assertIn(">MAME<", crumbs.group(0)) + + +if __name__ == "__main__": + unittest.main()