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()