From 1c3db5b4d8ba2a57b933e42ad729fa420500f129 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:45:04 +0200 Subject: [PATCH] fix: print each app's data folder per system --- scripts/generate_site.py | 422 +++++++++++++++++++++++++++++++++---- tests/test_site_exports.py | 5 +- 2 files changed, 383 insertions(+), 44 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index c1fbf1c9..c78aa385 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -55,6 +55,7 @@ from common import ( resolution_is_hash_exact, resolve_local_file, resolve_platform_cores, + runs_standalone, unique_emulator_profiles, yaml_load, ) @@ -7380,11 +7381,13 @@ def _render_emulator_file( anchor: str = "", owner: str = "", alias: str = "", + data_href: str = "#data-folder", ) -> list[str]: """Render one row of an emulator profile's file table. `anchor` is the card's id on the page, so one file can be cited by URL, - and `alias` a second id it answers to. + and `alias` a second id it answers to. `data_href` links the data + folder an app reads the entry under, from the page the card lands on. Split out of generate_emulator_page, where it was a 228-line loop body carrying most of that function's branching: thirty-odd optional fields, @@ -7436,18 +7439,23 @@ def _render_emulator_file( lines.append(_card_title(f"{marker}{label} {badge_str}", anchor, weight=5)) lines.append("") if desc: - lines.extend([_escape_tags(str(desc)), ""]) + lines.extend([data_root_words(_escape_tags(str(desc)), data_href), ""]) body_start = len(lines) details = [] - if fpath and fpath != fname: - details.append(f"Path: `{fpath}`") + # Where the app reads it under its data folder, said beside the path: + # Ryujinx's card read "Path: system/prod.keys" with no root. + place = standalone_place(owner, profile, f) if owner else "" + under = f", in [the data folder]({data_href})" standalone_path = f.get("standalone_path", "") + if (fpath and fpath != fname) or (place and place == fpath): + details.append(f"Path: `{fpath}`" + (under if place and place == fpath else "")) if standalone_path and standalone_path != fpath: # The standalone build reads it elsewhere: MesenCE's dsp1.rom goes # under Firmware/, and a page naming only the core's path sent its # users to the wrong folder. - details.append(f"Standalone path: `{standalone_path}`") + details.append(f"Standalone path: `{standalone_path}`" + + (under if place == standalone_path else "")) read_from = _load_from_text(f.get("load_from")) if read_from: details.append(f"Read from: {read_from}") @@ -7571,7 +7579,7 @@ def _render_emulator_file( for d in details: lines.append(f"- {d}") if fnote: - lines.append(f"- {_escape_tags(link_prose_citations(profile, str(fnote)))}") + lines.append(f"- {data_root_words(_escape_tags(link_prose_citations(profile, str(fnote))), data_href)}") if not in_repo and f.get("unsourceable"): # The badge alone sent readers after a dump that cannot exist, and the # reason was only on the gaps page, which the card did not link. @@ -7720,6 +7728,14 @@ def _render_metadata_rows(profile: dict) -> list[str]: lines.append(f"| {label} | [{val}]({val}) |") else: lines.append(f"| {label} | {val} |") + roots = profile.get("data_root") + if isinstance(roots, dict): + # The folder per system, for the reader who checks the profile. + for key in ("windows", "macos", "linux", "android", "ios"): + root = roots.get(key) + if isinstance(root, dict) and root.get("path"): + lines.append(f"| Data folder ({_OS_LABELS[key]}) | " + f"{_under_root(str(root['path']), '')} |") if profile.get("source_ref"): lines.append( f"| Source ref | {_source_ref_markdown(profile, profile['source_ref'])} |" @@ -7727,10 +7743,18 @@ def _render_metadata_rows(profile: dict) -> list[str]: return lines -def install_commands(args: str = "", indent: str = "") -> list[str]: +def install_commands(args: str = "", indent: str = "", + oses: tuple[str, ...] | None = None) -> list[str]: """The same install for each place a user runs it: one generator for the download page and the platform, system and emulator pages, so none of - them loses the Android or Windows form.""" + them loses the Android or Windows form. + + ``oses`` names the terminals (keys of TERMINALS) the command runs in, + written as the synced OS tabs: MAME4droid runs on Android only, and its + page offered a PowerShell command. None keeps the three blocks. + """ + if oses is not None: + return terminal_tabs(oses, dict.fromkeys(oses, args), indent) lines = [] for label, shell in ( ("Linux, macOS, Steam Deck:", "sh"), @@ -7741,29 +7765,294 @@ def install_commands(args: str = "", indent: str = "") -> list[str]: return lines +@functools.cache +def _installer() -> object: + """install.py, read as a module: the names `--emulator` accepts are its + own (`emulator_names`).""" + import importlib.util # noqa: PLC0415 + + spec = importlib.util.spec_from_file_location("install", _REPO_ROOT / "install.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@functools.cache +def _published_emulator_names() -> dict[str, dict[str, str]]: + """Per published manifest, every name `--emulator` accepts there and the + profile it stands for, as `install.py --list-emulators` prints them.""" + installer = _installer() + names: dict[str, dict[str, str]] = {} + for path in sorted((_REPO_ROOT / "install").glob("*.json")): + manifest = json.loads(path.read_text(encoding="utf-8")) + names[path.stem] = installer.emulator_names( + manifest, manifest.get("files") or [], manifest.get("omitted_files") or []) + return names + + +# Where a setup's command runs, in the order the tabs show them. +_COMMAND_TERMINALS = ("windows", "macos", "linux", "steamdeck", "android", "device") + +# The terminals an app's data_root key covers: the Deck runs the Linux build. +_DATA_ROOT_TERMINALS = { + "windows": ("windows",), "macos": ("macos",), "linux": ("linux", "steamdeck"), + "android": ("android",), "ios": (), +} + + +def emulator_install_route(name: str, profile: dict) -> tuple[str, list[str], tuple[str, ...]]: + """The name `--emulator` takes for this profile, the setups the command + fills, and the terminals it runs in, or ("", [], ()) when none works. + + A name works where a published manifest maps it to this profile: 215 + pages printed a command every manifest refused. A setup counts when the + installer finds it without `--dest`. A standalone app is reached only + through a setup that launches it as an app (its standalone_cores), on + the systems both run on: RetroArch's list names AetherSX2, and the + command filled RetroArch's system folder, which the app never reads. + """ + if profile.get("type") in ("alias", "launcher", "test"): + return "", [], () + if not [f for f in profile.get("files") or [] if isinstance(f, dict) and not comes_with_app(f)]: + return "", [], () + standalone = profile.get("type") == "standalone" + accepted: dict[str, set[str]] = {} + for platform, names in _published_emulator_names().items(): + if platform not in GUIDES: + continue + if standalone and not runs_standalone(name, profile, _standalone_cores(platform)): + continue + if any("--dest" in args for args in platform_args(platform, GUIDES[platform]).values()): + continue + own = {n for n, target in names.items() if target == name} + if own: + accepted[platform] = own + if not accepted: + return "", [], () + # The profile's own name where a setup takes it, else the alias most + # setups take: the one command must work on every setup it names. + candidates = set().union(*accepted.values()) + command = min(candidates, key=lambda n: ( + -sum(n in own for own in accepted.values()), n != name, n)) + order = list(GUIDES) + setups = sorted((p for p, own in accepted.items() if command in own), key=order.index) + terminals = {key for setup in setups for key in GUIDES[setup].run} + if isinstance(profile.get("data_root"), dict): + # Only the systems the program is built for (Cemu has no Android + # build). A device setup runs it itself: its terminal stays. + terminals &= {"device", *(t for key in profile["data_root"] + for t in _DATA_ROOT_TERMINALS.get(key, ()))} + oses = tuple(key for key in _COMMAND_TERMINALS if key in terminals) + if not oses: + return "", [], () + return command, setups, oses + + +@functools.cache +def _standalone_cores(platform: str) -> frozenset[str]: + """The emulators a setup launches as apps, by its platform file.""" + config = load_platform_config(platform, str(_REPO_ROOT / "platforms")) + return frozenset(str(c) for c in config.get("standalone_cores") or []) + + +def _said_or(names: list[str]) -> str: + return names[0] if len(names) == 1 else ", ".join(names[:-1]) + f" or {names[-1]}" + + def _install_block(name: str, profile: dict) -> list[str]: """One command that installs this emulator's files where it reads them. - The installer finds the platform and its BIOS folder, then keeps what - this emulator reads there: its own files and the platform's files of - the systems it runs. Each file is also linked one by one below. + The installer finds the setup and its BIOS folder, then keeps what this + emulator reads there: its own files and the setup's files of the + systems it runs. Written only where a published manifest takes the + name, in the terminals of the setups that run it. `--emulator` turns off + the pass that fills a lone app's folders (install.py, `narrowing` in + main), so an app on its own is named with the setup that serves it. """ - 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. + command, setups, oses = emulator_install_route(name, profile) + if not command: return [] + emu = str(profile.get("emulator") or name) + shown = [_platform_display(setup) for setup in setups] + if profile.get("type") == "standalone": + lead = (f"With {_said_or(shown)}: the installer finds {_said_or(shown)} and its " + f"BIOS folder, then downloads only the files {emu} reads there and checks " + "each one.") + else: + lead = ("The installer finds your setup and its BIOS folder, then downloads " + "only the files this emulator reads there and checks each one.") + if profile.get("type") == "standalone + libretro": + # The command fills a setup, never the app's own folder. + where = (_said_or(shown) if len(shown) <= 3 + else f"{shown[0]}, {shown[1]} or another setup") + lead = f"For {emu} inside {where}. {lead}" 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.", + lead, "", - *install_commands(f"--emulator {name}"), + *install_commands(f"--emulator {command}", oses=oses), ])) +# Entries whose folder the app opens is not data_root joined to their +# standalone_path (or path): the pack builder still places them by that +# field, so the joined path names a place the app never reads. Their page +# gives the data folder alone. A row names the profile, the entries (a name, +# a path prefix ending in "/", or "*" for every file) and, when the rule +# holds on some systems only, the data_root keys it covers. +FOLDER_ONLY: tuple[tuple[str, tuple[str, ...], tuple[str, ...], str], ...] = ( + ("amiberry", ("*",), (), + "the app reads every ROM, Kickstarts included, under ROMs/ of its folder"), + ("fsuae", ("fsuae/Kickstarts/",), (), + "the app reads Kickstarts from ROMs/Kickstart/ of its folder"), + ("citra", ("Citra/sysdata/",), (), + "the app reads its keys and shared fonts from sysdata/ of its folder"), + ("ymir", ("*",), (), + "the app reads its IPL, cartridge and CD-block ROMs from roms/ipl/, roms/cart/ and roms/cdb/"), + ("mame", ("*",), (), + "the app reads every set under roms/ of its working directory"), + ("eka2l1", ("data/",), ("ios",), + "on iOS the data folder is the data-storage folder itself, so data/ would be one level too deep"), + ("aethersx2", ("resources/", "eeprom.dat", "flash.dat"), (), + "resources sit beside the program, and eeprom.dat and flash.dat in the working directory"), + ("cemu", ("resources/sharedFonts/",), (), + "the shared fonts are read from the program's data path"), + ("cxbx-reloaded", ("hlsl/",), (), + "the shaders are read from beside the program"), + ("ppsspp-1.5.4", ("ppsspp-1.5.4/assets/",), (), + "the assets tree is read from beside the program"), + ("yaps2", ("resources/",), (), + "resources are read from beside the program"), + ("hatari", ("tos.img", "tos.sym"), (), + "the TOS image is read from the install data directory"), + ("armsx2", ("proverb.zip",), ("android",), + "the Android app keeps the arcade boot programs in its internal storage"), + ("armsx2", ("eeprom.dat", "flash.dat"), (), + "eeprom.dat and flash.dat are read from the working directory"), + ("kenji-nx", ("drivers/",), (), + "a driver archive is unpacked under drivers/, a layout not confirmed in the source"), +) + + +def folder_only_matches(pattern: str, entry: dict) -> bool: + """Whether a FOLDER_ONLY pattern names this entry.""" + if pattern == "*": + return True + if pattern.endswith("/"): + return any(str(entry.get(key) or "").startswith(pattern) + for key in ("path", "standalone_path")) + return entry.get("name") == pattern + + +def _folder_only(name: str, entry: dict) -> set[str]: + """The data_root keys on which this entry's joined path is not the app's + folder: every key ({"*"}), some, or none.""" + keys: set[str] = set() + for profile, patterns, only, _reason in FOLDER_ONLY: + if profile == name and any(folder_only_matches(p, entry) for p in patterns): + keys |= set(only) or {"*"} + return keys + + +def standalone_place(name: str, profile: dict, entry: dict, key: str = "") -> str: + """Where under the data folder the app reads this entry, or "". + + Only what the app opens there: a lone app's ``standalone_path`` or + ``path``, a dual profile's ``standalone_path`` or the ``path`` of an + entry its standalone build alone reads. Nothing for an entry found by + name, one the app ships, one the libretro core alone reads, or one + FOLDER_ONLY lists (on ``key``, or on any key when ``key`` is empty). + """ + if not isinstance(entry, dict) or not profile.get("data_root"): + return "" + mode = entry.get("mode") + if mode == "libretro" or (entry.get("bundled") is True and mode in (None, "standalone")): + return "" + excluded = _folder_only(name, entry) + if "*" in excluded or (key in excluded if key else excluded): + return "" + if profile.get("type") == "standalone" or mode == "standalone": + return str(entry.get("standalone_path") or entry.get("path") or "") + return str(entry.get("standalone_path") or "") + + +_OS_LABELS = {"windows": "Windows", "macos": "macOS", "linux": "Linux", + "android": "Android", "ios": "iOS"} + + +def _under_root(root: str, folder: str) -> str: + """``root`` with ``folder`` below it, as code: each alternative of + "$XDG_CONFIG_HOME/x, else ~/.config/x", and a root written in words + ("the folder picked in the setup wizard") kept as words.""" + folder = folder.strip("/") + shown = [] + for alternative in root.split(", else "): + alternative = alternative.strip() + if " " in alternative and "/" not in alternative and not alternative.startswith("<"): + shown.append(f"{_code(folder + '/')} in {_escape_tags(alternative)}" + if folder else _escape_tags(alternative)) + continue + joined = alternative.rstrip("/") + (f"/{folder}" if folder else "") + "/" + shown.append(_code(joined)) + return ", else ".join(shown) + + +def _data_root_folder(name: str, profile: dict, key: str, entries: list[dict]) -> str: + """The one folder under the root that every entry the app reads sits + in, or "": VICE places 3 of its 322 files in DRIVES/, and naming that + folder would send the other 319 there.""" + folders = set() + for f in entries: + mode = f.get("mode") + if mode == "libretro" or (f.get("bundled") is True and mode in (None, "standalone")): + continue + place = standalone_place(name, profile, f, key) + if not place: + return "" + folders.add(place.rpartition("/")[0]) + return folders.pop() if len(folders) == 1 else "" + + +def data_folder_block(name: str, profile: dict, entries: list[dict] | None = None) -> list[str]: + """The app's data folder, one tab per system it runs on, each with the + code that names it and its note: 61 profiles cite it and no page showed + it. ``entries`` are the files the folder is for: when they all sit in + one folder below the root, the tab names that folder.""" + roots = profile.get("data_root") or {} + if not isinstance(roots, dict) or not roots: + return [] + if entries is None: + entries = [f for f in profile.get("files") or [] if isinstance(f, dict)] + items = [] + for key in ("windows", "macos", "linux", "android", "ios"): + root = roots.get(key) + if not isinstance(root, dict) or not root.get("path"): + continue + path = str(root["path"]) + line = _under_root(path, _data_root_folder(name, profile, key, entries)) + if key == "android" and path.startswith("Android/data/"): + line += ", on the internal storage." + body = [line] + note = " ".join(str(root.get("note") or "").split()).rstrip(".") + if note: + # "the folder" reads as a sentence, "portable.ini" keeps its case. + first = note.split(" ", 1)[0] + if first.isalpha() and first.islower(): + note = note[:1].upper() + note[1:] + body += ["", _escape_tags(note) + "."] + body += ["", f"Source: {_source_ref_markdown(profile, str(root['source_ref']))}"] + items.append((_OS_LABELS[key], body)) + if len(items) == 1: + label, body = items[0] + return [f"On {label}: {body[0]}", *body[1:], ""] + return markup.tabs(items) + + +def data_root_words(text: str, href: str = "#data-folder") -> str: + """Prose that names the field data_root, linked to the folder it holds: + ten pages told readers "given in data_root".""" + return re.sub(r"\bdata_root\b", f"[the data folder]({href})", text) + + def _platform_install_block(name: str, display: str, on_card: bool = False) -> list[str]: """The platform's pack, installed file by file: what the ZIP holds, with resume, verification and only what is missing. @@ -7922,8 +8211,10 @@ def _render_structured_blocks(profile: dict) -> list[str]: body: list[str] = [] _render_yaml_value(body, val, indent=0, field_text=_structured_field_text(profile)) + # Closed: raw profile data, below the files (Cemu's firmware + # titles opened the page above them). block = markup.details( - "abstract", label, [_escape_tags(line) for line in body], opened=True) + "abstract", label, [_escape_tags(line) for line in body]) # A block listing hashes is the lookup's, as a card's hash lines are: # izapple2's ROM table put "md5" 59 times in the search, above the # page that explains MD5 checks. @@ -8016,6 +8307,12 @@ def _emulator_file_summary(files: list[dict]) -> list[str]: if sum(len(group) for group in slots) != len(slots): total += f" ({sum(len(group) for group in slots):,} accepted versions)" line = f"{total}: {req_count:,} required, {len(slots) - req_count:,} optional." + if _regional_choice([f for f in files if f.get("required") is True and not comes_with_app(f)]): + # DuckStation takes the BIOS of the game's region: "3 required" + # read as "get all three". + line = (f"{total}. One of {req_count:,} is required, for your games' region. " + f"{len(slots) - req_count:,} {'is' if len(slots) - req_count == 1 else 'are'} " + "optional.") if hle_count: line += f" {hle_count:,} {'has' if hle_count == 1 else 'have'} an HLE fallback." if shipped: @@ -8322,44 +8619,77 @@ def _any_name_folder(profile: dict, entry: dict) -> str: 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" + own = ("[the app's data folder](#data-folder)" if profile.get("data_root") + else "the app's own data folder") + return f"the {_code(folder.strip('/'))} folder inside {own}" return f"{_code(folder)}, inside your BIOS folder" -def _any_name_steps(emu_name: str, profile: dict, entry: dict, href: str) -> str: +def _any_name_steps(emu_name: str, profile: dict, entry: dict, href: str, + name: str = "") -> str: """The intro of an emulator that takes one BIOS of any name: get one, - put it in its folder, any name works.""" + put it in its folder, any name works. + + An app whose profile names its data folder gets that folder, one tab + per system it runs on: AetherSX2's step said "inside the app's own + data folder" while the profile cites Android/data/xyz.aethersx2.android. + """ 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})." + example = str(entry.get("name") or "") + if _named_example(example) and href: + get = f"Download [{_code(example)}]({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}.") + folders: list[str] = [] + if profile.get("type") == "standalone" and standalone_place(name, profile, entry): + steps.append("Put it in this folder:") + folders = ['
', "", + *data_folder_block(name, profile, [entry])] + else: + folder = _any_name_folder(profile, entry) + if folder: + steps.append(f"Put it in {folder}.") + folders = _data_folder_intro(name, profile) 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)), "", + *folders, + *([""] if folders and folders[-1] else []), f"Any file name works. {reader} reads what the file holds.", ]) -def _emulator_intro(emu_name: str, profile: dict, href: str = "") -> str: +def _data_folder_intro(name: str, profile: dict) -> list[str]: + """Where the app keeps its files, under the intro of a profile that + names its data folder and takes more than one BIOS of any name.""" + if not profile.get("data_root"): + return [] + files = [f for f in profile.get("files") or [] if isinstance(f, dict)] + placed = [f for f in files if standalone_place(name, profile, f)] + subject = ("The app" if profile.get("type") == "standalone" + else "Run on its own, the app") + lead = (f"{subject} reads its files from this folder:" if placed + else f"{subject} keeps its data in this folder:") + return ['
', "", lead, "", + *data_folder_block(name, profile, files)] + + +def _emulator_intro(emu_name: str, profile: dict, href: str = "", name: 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. ``href`` links the - collection's copy of the one BIOS an emulator takes under any name. + collection's copy of the one BIOS an emulator takes under any name, + and ``name`` is the profile's key. """ entry = any_name_bios(profile) if entry is not None: - return _any_name_steps(emu_name, profile, entry, href) + return _any_name_steps(emu_name, profile, entry, href, name) names: list[str] = [] for sys_id in profile.get("systems") or []: label = system_name(sys_id) @@ -8395,7 +8725,8 @@ def _emulator_intro(emu_name: str, profile: dict, href: str = "") -> str: sentences.append("It recognises a file by its contents, so any file name works.") if any(f.get("source_ref") for f in files): sentences.append("Each file below cites the line of source code that reads it.") - return " ".join(sentences) + folder = _data_folder_intro(name, profile) if name else [] + return "\n".join([" ".join(sentences), "", *folder]).strip("\n") def _regional_choice(required: list[dict], most: int = 6) -> str: @@ -8467,7 +8798,7 @@ def emulator_pages( part_pages: dict[str, str] = {} one_bios = any_name_bios(profile) intro = _emulator_intro(emu_name, profile, _collection_card_href( - one_bios, consumers, name) if one_bios else "") + one_bios, consumers, name) if one_bios else "", name) romset = emulator_romset(profile) lines = [ f"# {emu_name}", @@ -8565,10 +8896,9 @@ def emulator_pages( lines.extend(_render_platform_details(profile)) - lines.extend(_render_structured_blocks(profile)) - if notes: - body = _admonition_body(link_prose_citations(profile, notes)).replace("\n ", "\n") + body = _admonition_body(data_root_words(link_prose_citations(profile, notes)) + ).replace("\n ", "\n") lines.extend(markup.details("note", "Technical notes", body.split("\n"))) if not files: @@ -8585,11 +8915,13 @@ def emulator_pages( anchors = emulator_file_anchors(files) aliases = emulator_file_aliases(files, anchors) + parts = emulator_parts(name, files) + data_href = f"{name}.md#data-folder" if parts else "#data-folder" cards = [ - _render_emulator_file(f, profile, consumers, files, _file_available, anchor, name, alias) + _render_emulator_file(f, profile, consumers, files, _file_available, anchor, name, + alias, data_href) for f, anchor, alias in zip(files, anchors, aliases) ] - parts = emulator_parts(name, files) if not parts: order = _display_order(files) indexed = len(files) >= EMULATOR_SUMMARY_MIN @@ -8614,6 +8946,10 @@ def emulator_pages( name, emu_name, number, parts, [cards[i] for i in entries], [files[i] for i in entries]) + blocks = _render_structured_blocks(profile) + if blocks: + lines.extend(["## Profile data", "", *blocks]) + if data_dirs: # The member tables are a file list with hashes: out of the search, # dolphin, primehack and ishiiruka each put 210 KB of it there. @@ -8747,8 +9083,8 @@ def _emulator_part_page( 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 " - f"command and every entry. This part holds {_count(len(files), 'entry', 'entries')}, " + # The crumbs end on the emulator, read from parent: (round 5, 7.5). + f"This part holds {_count(len(files), 'entry', 'entries')}, " f"{_code(first)} to {_code(last)}. The other parts: {others}.", "", *_sectioned(files, list(range(len(files))), diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index cae5191a..efc0490b 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -11,6 +11,8 @@ import unittest from unittest import mock from pathlib import Path +import yaml + ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) @@ -896,7 +898,8 @@ class AnEmulatorPageOffersItsInstall(unittest.TestCase): def test_the_command_names_the_emulator(self): from generate_site import _install_block - text = "\n".join(_install_block("pcem", {"type": "libretro"})) + profile = yaml.safe_load((ROOT / "emulators" / "pcem.yml").read_text(encoding="utf-8")) + text = "\n".join(_install_block("pcem", profile)) self.assertIn("install.sh | sh -s -- --emulator pcem", text) self.assertIn("install.ps1))) --emulator pcem", text) self.assertEqual(_install_block("x", {"type": "alias"}), [])