fix: print each app's data folder per system

This commit is contained in:
Abdessamad Derraz committed 2026-10-24 02:49:23 +02:00
1 parent 0e5aa804a7
commit 1c3db5b4d8
2 files changed
+383 -44

No files matched your search

+379 -43
View File
@@ -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 = ['<div id="data-folder" class="rb-anchor"></div>', "",
*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 ['<div id="data-folder" class="rb-anchor"></div>', "", 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))),
+4 -1
View File
@@ -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"}), [])