From 516736147367968b158c797ab1acd4003868f6f9 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:42:11 +0200 Subject: [PATCH 1/3] feat: readable diagrams and wiki command tabs Diagram text reads at 14 px in the column, boxes take the height of their lines, paths are mono on a line of their own. One setup count for every page. Wiki pages expand install commands into system tabs, an options table read from install.py, and keep example hashes out of search. --- docs_assets/og/home.png | Bin 22442 -> 67092 bytes scripts/sitediagrams.py | 325 +++++++++++++++++++++++++++++++------- tests/test_site_images.py | 3 +- wiki/getting-started.md | 24 --- 4 files changed, 267 insertions(+), 85 deletions(-) delete mode 100644 wiki/getting-started.md diff --git a/docs_assets/og/home.png b/docs_assets/og/home.png index 3d2b7e17573b63dfd6b987f2ccad1083330f0f93..a10a74f97c12f19b2f3d6bab53c43aca498980b3 100644 GIT binary patch literal 67092 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[ PLACEHOLDER_ANYWHERE = re.compile(r"") FONT = "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" -TITLE_SIZE = 14 -TEXT_SIZE = 12.5 -LINE = 17 -PAD = 10 +# The stylesheet sets the page font on every , so a path takes the +# mono font from an inline style, which outranks it. +MONO_STYLE = "font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)" WIDE = 720 NARROW = 340 +# The wide drawing is scaled to a column about 700 px wide: these sizes read +# at 14 px of body text there, the size of the prose around the figure. +COLUMN = 700 +TEXT_SIZE = round(14 * WIDE / COLUMN, 1) +TITLE_SIZE = round(15 * WIDE / COLUMN, 1) +LINE = 20 +PAD = 12 +# A box holds a title and at most this many lines: the rest is the caption's. +MAX_BOX_LINES = 3 # Default colours for an SVG read outside the page. The stylesheet replaces # them on the site through the rb-d-* classes. @@ -58,13 +66,20 @@ ACCENT = "#3b3f7a" MUTED = "#546e7a" +class Mono(str): + """A path or a name typed as is: drawn in the mono font on a line of its + own, never broken.""" + + def _width(text: str, size: float, bold: bool = False) -> float: - """Rough advance of a sans-serif string: 0.56 em a character, 0.6 bold.""" - return len(text) * size * (0.6 if bold else 0.56) + """Rough advance of a string: 0.56 em a character, 0.6 bold or mono.""" + return len(text) * size * (0.6 if bold or isinstance(text, Mono) else 0.56) def wrap(text: str, width: float, size: float, bold: bool = False) -> list[str]: - """Greedy word wrap against the estimated advance.""" + """Greedy word wrap against the estimated advance. A Mono line stays whole.""" + if isinstance(text, Mono): + return [text] lines: list[str] = [] current = "" for word in text.split(): @@ -111,10 +126,11 @@ def _text(x: float, y: float, value: str, size: float = TEXT_SIZE, bold: bool = False, anchor: str = "start", cls: str = "") -> str: weight = ' font-weight="600"' if bold else "" klass = f' class="{cls}"' if cls else "" + style = f' style="{MONO_STYLE}"' if isinstance(value, Mono) else "" fill = MUTED if cls == "rb-d-muted" else ("#ffffff" if cls == "rb-d-numtext" else INK) return ( f'{escape(value)}' + f'text-anchor="{anchor}" fill="{fill}"{klass}{style}>{escape(value)}' ) @@ -167,13 +183,19 @@ def _line(points: list[tuple[float, float]], head: bool = True) -> list[str]: return out -def _box_height(box: Box, width: float) -> tuple[list[str], list[str], float]: +def box_lines(box: Box, width: float) -> tuple[list[str], list[str]]: + """The title and body lines of *box* drawn *width* wide, as wrapped.""" inner = width - 2 * PAD title_lines = wrap(box.title, inner - (_badge_side(box.number) + 8 if box.number else 0), TITLE_SIZE, True) body: list[str] = [] for line in box.lines: body.extend(wrap(line, inner, TEXT_SIZE)) + return title_lines, body + + +def _box_height(box: Box, width: float) -> tuple[list[str], list[str], float]: + title_lines, body = box_lines(box, width) height = PAD + len(title_lines) * LINE + (6 if body else 0) + len(body) * LINE + PAD if box.number: height = max(height, PAD * 2 + 20) @@ -187,7 +209,7 @@ def _draw_box(box: Box, x: float, y: float, width: float, height: float, if box.number: out.extend(_badge(x + PAD, y + PAD, box.number)) tx += _badge_side(box.number) + 8 - cy = y + PAD + 13 + cy = y + PAD + 15 for line in title_lines: out.append(_text(tx, cy, line, TITLE_SIZE, True)) cy += LINE @@ -216,9 +238,10 @@ def flow(boxes: list[Box], vertical: bool, total: float) -> tuple[float, float, width = (total - gap * (len(boxes) - 1) - 2) / len(boxes) measured = [_box_height(box, width) for box in boxes] height = max(m[2] for m in measured) - for index, (box, (title_lines, body, _)) in enumerate(zip(boxes, measured)): + for index, (box, (title_lines, body, own)) in enumerate(zip(boxes, measured)): x = 1 + index * (width + gap) - out.extend(_draw_box(box, x, 1, width, height, title_lines, body)) + # A box is as tall as its lines, centred on the row the arrows run along. + out.extend(_draw_box(box, x, 1 + (height - own) / 2, width, own, title_lines, body)) if index < len(boxes) - 1: mid = 1 + height / 2 out.extend(_line([(x + width, mid), (x + width + gap - 1, mid)])) @@ -239,8 +262,8 @@ def columns(boxes: list[Box], vertical: bool, total: float) -> tuple[float, floa width = (total - gap * (len(boxes) - 1) - 2) / len(boxes) measured = [_box_height(box, width) for box in boxes] height = max(m[2] for m in measured) - for index, (box, (title_lines, body, _)) in enumerate(zip(boxes, measured)): - out.extend(_draw_box(box, 1 + index * (width + gap), 1, width, height, title_lines, body)) + for index, (box, (title_lines, body, own)) in enumerate(zip(boxes, measured)): + out.extend(_draw_box(box, 1 + index * (width + gap), 1, width, own, title_lines, body)) return total, height + 2, out @@ -296,13 +319,28 @@ def registry() -> dict: return yaml.safe_load(handle).get("platforms", {}) +@lru_cache(maxsize=None) +def setups() -> tuple[str, ...]: + """The registered setups, archived ones included: each has its own page, + pack and file list, so every count of setups on the site is this one.""" + from common import list_registered_platforms + + return tuple(sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True))) + + +def setups_named(named: list[str]) -> str: + """*named*, then how many other setups there are: "A, B and 15 more".""" + others = len(setups()) - len(named) + return ", ".join(named) + (f" and {others} more" if others else "") + + @lru_cache(maxsize=None) def platform_modes() -> dict[str, tuple[str, str, str | None]]: """name -> (display name, verification mode, hash rule) for every platform.""" - from common import list_registered_platforms, load_platform_config + from common import load_platform_config out: dict[str, tuple[str, str, str | None]] = {} - for name in sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True)): + for name in setups(): config = load_platform_config(name, str(ROOT / "platforms")) rule = registry().get(name, {}).get("hash_rule") or config.get("hash_rule") out[name] = (config.get("platform", name), config.get("verification_mode", ""), rule) @@ -335,64 +373,70 @@ def emulator_names() -> dict[str, str]: def _bios_chain_boxes() -> list[Box]: - active = sum(1 for entry in registry().values() if entry.get("status") == "active") - frontends = [_display(platform) for platform, _ in CHAIN_EXAMPLES] - emulators = [emulator_names()[emulator] for _, emulator in CHAIN_EXAMPLES] - folders = [f"{_display(platform)}: {_folder(platform)}" for platform, _ in CHAIN_EXAMPLES] + platform, _emulator = CHAIN_EXAMPLES[0] return [ Box("Your device", ["A PC, a Steam Deck, a handheld or a TV box"], number="1"), - Box("Frontend", [ - "The menu you pick a game from:", - f"{', '.join(frontends)} and {active - len(frontends)} more", - ], number="2"), - Box("Emulator", [ - "Starts the game and reads its BIOS:", - ", ".join(emulators), - ], number="3"), - Box("BIOS folder", folders + ["The pack or the installer fills it"], + Box("Setup", ["The menu you pick a game from"], number="2"), + Box("Emulator", ["Starts the game and reads its BIOS"], number="3"), + Box("BIOS folder", [f"{_display(platform)} reads", Mono(_folder(platform))], accent=True, number="4"), ] +def _bios_chain_caption() -> str: + """The examples the boxes leave out, read from the registry and profiles.""" + named = setups_named([_display(platform) for platform, _ in CHAIN_EXAMPLES]) + pairs = ", ".join( + f"{_display(platform)} starts {emulator_names()[emulator]}" + for platform, emulator in CHAIN_EXAMPLES + ) + folders = ", ".join(f"{_display(platform)} {_folder(platform)}" for platform, _ in CHAIN_EXAMPLES) + return ( + f"Setups: {named}. Examples: {pairs}. " + f"BIOS folders: {folders}. The ZIP or the command fills that folder." + ) + + def _install_boxes() -> list[Box]: - detected = sum(1 for entry in registry().values() if entry.get("install", {}).get("detect")) return [ - Box("Run one command", ["The same line on Linux, macOS, Windows and handhelds"], number="1"), - Box("Find the setup", [f"Looks for {detected} frontends and their BIOS folders"], number="2"), - Box("Read the folder", ["Hashes the files already there"], number="3"), - Box("Download the rest", ["Only what is missing or different"], number="4"), - Box("Check each file", ["Its SHA-1 must match before it is saved"], accent=True, number="5"), + Box("Finds your setup", ["And the folder it reads"], number="1"), + Box("Looks at your files", ["The ones already in the folder"], number="2"), + Box("Downloads the rest", ["Only what is missing or different"], number="3"), + Box("Checks each file", ["Before saving it"], accent=True, number="4"), ] MODE_TEXT = { - "existence": ("Name only", "Only the file's name and place count. Its content is not read."), + "existence": ("Name only", "Only the name and place count."), "md5": ("MD5 fingerprint", "The content must match an MD5 the platform lists."), "sha1": ("SHA-1 fingerprint", "The content must match a SHA-1 the platform lists."), } -def _mode_boxes() -> list[Box]: +def _modes() -> dict[str, list[str]]: + """Mode -> the platforms checking that way, RomM's size rule named.""" by_mode: dict[str, list[str]] = {mode: [] for mode in MODE_TEXT} for display, mode, rule in platform_modes().values(): label = f"{display} (size, then any hash)" if rule == "size_and_any_hash" else display by_mode.setdefault(mode, []).append(label) + return {mode: sorted(names, key=str.casefold) for mode, names in by_mode.items()} + + +def _mode_boxes() -> list[Box]: + """Each way of checking and how many platforms use it. The names are in + the caption: eleven of them do not fit in three lines of a box.""" boxes = [] for mode, (title, text) in MODE_TEXT.items(): - names = sorted(by_mode.get(mode, []), key=str.casefold) - count = f"{len(names)} platform" + ("" if len(names) == 1 else "s") - boxes.append(Box(title, [text, f"{count}: " + ", ".join(names)])) + count = len(_modes().get(mode, [])) + boxes.append(Box(title, [text, f"{count} platform" + ("" if count == 1 else "s")])) return boxes def _mode_summary() -> str: - parts = [] - for mode, (title, _) in MODE_TEXT.items(): - names = sorted( - (display for display, m, _ in platform_modes().values() if m == mode), key=str.casefold - ) - parts.append(f"{title}: {', '.join(names)}.") - return " ".join(parts) + return " ".join( + f"{title}: {', '.join(_modes().get(mode, []))}." + for mode, (title, _) in MODE_TEXT.items() + ) # MinUI names a BIOS folder after the tag in parentheses of the ROM folder. @@ -529,21 +573,20 @@ def _figures() -> dict[str, Figure]: "bios-chain": Figure( "bios-chain", "How a BIOS file is used", - "A device runs a frontend, the frontend starts an emulator, and the emulator " - "reads BIOS files from one folder. The pack or the installer fills that folder.", - "The frontend is the menu, the emulator runs the game, and the BIOS folder is the " - "one place both look. Filling that folder is all a pack does.", + "A device runs a setup, the setup starts an emulator, and the emulator " + "reads BIOS files from one folder. The ZIP or the command fills that folder.", + _bios_chain_caption(), lambda: flow(chain(), False, WIDE), lambda: flow(chain(), True, NARROW), ), "install-flow": Figure( "install-flow", - "What the installer does", - "Install flow: run the command, the installer finds your setup, reads the BIOS " - "folder, downloads what is missing or different, then checks each file before " + "What the command does", + "The command finds your setup and its BIOS folder, looks at the files already " + "there, downloads what is missing or different, then checks each file before " "saving it.", "Running the command again repeats the same steps and downloads only what changed.", - lambda: flow(install(), False, WIDE), + lambda: grid(install(), 2, WIDE), lambda: flow(install(), True, NARROW), ), "verification-modes": Figure( @@ -551,7 +594,7 @@ def _figures() -> dict[str, Figure]: "How each platform checks a file", "Three ways platforms check a BIOS file. " + _mode_summary(), "Each platform's own check, read from its source code. A file that passes on one " - "platform can be refused on another.", + "platform can be refused on another. " + _mode_summary(), lambda: columns(_mode_boxes(), False, WIDE), lambda: columns(_mode_boxes(), True, NARROW), ), @@ -689,9 +732,174 @@ def localize_images(markdown: str, page: Path) -> str: return IMG_SRC.sub(swap, IMAGE_LINK.sub(swap, markdown)) +# Installer commands on wiki pages. The page keeps the Linux line, readable +# where the Markdown is read as is, and the site shows it in one tab per +# system, the Windows form written by the same function as the setup pages'. + +OS_TABS = re.compile( + r"(?m)^(?P[ \t]*)[ \t]*\n" + r"(?P=indent)```(?:sh|bash)[^\n]*\n(?P.*?)\n(?P=indent)```[ \t]*$", + re.S, +) +OS_TAB_KEYS = ("windows", "macos", "linux", "steamdeck") +FENCE_TITLES = {"sh": "Terminal", "powershell": "PowerShell"} + + +def _installer_args(body: str) -> str: + """The options of a one-line install command, continuation lines joined.""" + from siteguides import INSTALL_BASE + + line = " ".join(part.strip().rstrip("\\").strip() for part in body.splitlines()) + match = re.fullmatch(rf"curl -fsSL {re.escape(INSTALL_BASE)}/install\.sh \| sh(?: -s -- (.+))?", line) + if not match: + raise ValueError(f"an os-tabs block holds no install command: {line!r}") + return match.group(1) or "" + + +def expand_commands(markdown: str) -> str: + """Each install command marked `` as Windows, macOS, + Linux and Steam Deck tabs. `` gives the + Windows tab its own options, for a path written another way there.""" + if "os-tabs" not in markdown: + return markdown + from siteguides import TERMINALS, install_command + + def replace(match: re.Match[str]) -> str: + indent = match.group("indent") + args = _installer_args(match.group("body")) + items = [] + for key in OS_TAB_KEYS: + label, _how, shell = TERMINALS[key] + own = match.group("windows") if key == "windows" and match.group("windows") else args + items.append((label, [f'```{shell} title="{FENCE_TITLES[shell]}"', + install_command(shell, own), "```"])) + return "\n".join(markup.tabs(items, indent)).rstrip("\n") + + return OS_TABS.sub(replace, markdown) + + +# The installer's options at a glance, read from its argument parser. + +OPTIONS_MARKER = re.compile(r"(?m)^[ \t]*$") +_SEE_LINK = re.compile(r"\(#([a-z0-9-]+)\)") + + +def _string(node: ast.expr, constants: dict[str, str]) -> str: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.JoinedStr): + parts = [] + for value in node.values: + if isinstance(value, ast.FormattedValue) and isinstance(value.value, ast.Name): + parts.append(constants[value.value.id]) + else: + parts.append(_string(value, constants)) + return "".join(parts) + raise ValueError(f"install.py: an option help that is not a string: {ast.unparse(node)}") + + +@lru_cache(maxsize=None) +def installer_options() -> tuple[tuple[str, str], ...]: + """(usage, help) for each option of install.py, in the order --help lists them.""" + tree = ast.parse((ROOT / "install.py").read_text(encoding="utf-8")) + constants = { + node.targets[0].id: node.value.value + for node in tree.body + if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) + } + main = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "main") + options = [] + for node in ast.walk(main): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr == "add_argument"): + continue + flags = [arg.value for arg in node.args if isinstance(arg, ast.Constant)] + keywords = {kw.arg: kw.value for kw in node.keywords} + action = keywords.get("action") + takes_value = not (isinstance(action, ast.Constant) + and action.value in ("store_true", "store_false", "version")) + metavar = "" + if takes_value: + given = keywords.get("metavar") + metavar = given.value if isinstance(given, ast.Constant) else flags[0].lstrip("-").upper() + usage = ", ".join(f"{flag} {metavar}".strip() for flag in flags) + options.append((usage, _string(keywords["help"], constants) if "help" in keywords else "")) + # argparse's own, added to every parser. + options.append(("--help, -h", "show this help message and exit")) + return tuple(options) + + +def _sentences(text: str) -> str: + """A help string as sentences: capitalised, clauses split, a full stop.""" + parts = [part.strip() for part in text.split("; ")] + text = ". ".join(part[0].upper() + part[1:] for part in parts if part) + text = re.sub(r"(? list[str]: + """One row per option: what it does, and the section that says more, + the one the option's row in the page's own Options table sends to.""" + sections: dict[str, str] = {} + for line in markdown.splitlines(): + flag = re.match(r"\| `(--[a-z-]+)", line) + see = _SEE_LINK.search(line) + if flag and see: + sections[flag.group(1)] = see.group(1) + headings = { + re.sub(r"[^a-z0-9 -]", "", title.lower()).strip().replace(" ", "-"): title + for title in re.findall(r"(?m)^##+ (.+?)(?:\s*\{[^}]*\})?\s*$", markdown) + } + rows = ["| Option | What it does | More |", "|---|---|---|"] + for usage, help_text in installer_options(): + anchor = sections.get(usage.split()[0].rstrip(",")) + more = f"[{headings.get(anchor, anchor)}](#{anchor})" if anchor else "" + rows.append(f"| `{usage}` | {_sentences(help_text)} | {more} |") + return rows + + +def expand_options(markdown: str) -> str: + if "installer-options" not in markdown: + return markdown + return OPTIONS_MARKER.sub(lambda _m: "\n".join(options_table(markdown)), markdown) + + +# Example hashes in the documentation stay out of search, so a hash typed in +# the search box finds the file card that holds it, not a page quoting it. + +HASH_SPAN = re.compile(r"`[^`\n]*\b(?:[0-9a-f]{64}|[0-9a-f]{40}|[0-9a-f]{32})\b[^`\n]*`") +HASH_TEXT = re.compile(r"\b(?:[0-9a-f]{64}|[0-9a-f]{40}|[0-9a-f]{32})\b") +FENCE = re.compile(r"(?m)^(?P[ \t]*)(?P```|~~~)[^\n]*\n.*?\n(?P=indent)(?P=mark)[ \t]*$", re.S) + + +def hide_hashes(markdown: str) -> str: + """Wrap each code block and code span quoting a hash in a search exclusion.""" + if not HASH_TEXT.search(markdown): + return markdown + attribute = markup.search_exclude() + pieces: list[str] = [] + position = 0 + for match in FENCE.finditer(markdown): + pieces.append(HASH_SPAN.sub(lambda m: f"{m.group(0)}", + markdown[position:match.start()])) + block = match.group(0) + if HASH_TEXT.search(block): + indent = match.group("indent") + block = f"{indent}
\n\n{block}\n\n{indent}
" + pieces.append(block) + position = match.end() + pieces.append(HASH_SPAN.sub(lambda m: f"{m.group(0)}", + markdown[position:])) + return "".join(pieces) + + def prepare_page(markdown: str, page: Path) -> str: """Everything this module does to a page before it is decorated.""" - return resolve_images(localize_images(markdown, page)) + markdown = expand_options(expand_commands(resolve_images(localize_images(markdown, page)))) + if page.parts[:1] == ("wiki",): + markdown = hide_hashes(markdown) + return markdown # Sharing cards: one 1200x630 card per section of the site, rendered to PNG @@ -711,8 +919,7 @@ def og_card_svg(section: str, logo_png: bytes) -> str: import base64 title, tagline = OG_SECTIONS[section] - active = sum(1 for entry in registry().values() if entry.get("status") == "active") - tagline = tagline.format(frontends=f"RetroArch, Batocera, EmuDeck and {active - 3} more") + tagline = tagline.format(frontends=setups_named(["RetroArch", "Batocera", "EmuDeck"])) logo = base64.b64encode(logo_png).decode("ascii") body = [ '', diff --git a/tests/test_site_images.py b/tests/test_site_images.py index 2084bc56..969550cc 100644 --- a/tests/test_site_images.py +++ b/tests/test_site_images.py @@ -96,8 +96,7 @@ class PlaceholdersResolve(unittest.TestCase): class DiagramsMatchTheData(unittest.TestCase): def test_every_platform_appears_under_its_mode(self): - boxes = sitediagrams._mode_boxes() - text = " ".join(" ".join(box.lines) for box in boxes) + text = sitediagrams.figures()["verification-modes"].caption for display, mode, _ in sitediagrams.platform_modes().values(): with self.subTest(platform=display): self.assertIn(mode, sitediagrams.MODE_TEXT) diff --git a/wiki/getting-started.md b/wiki/getting-started.md deleted file mode 100644 index ec50eee6..00000000 --- a/wiki/getting-started.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -level: start -nav: false ---- - -# Getting started - -Three pages take you from nothing to games that start. - -
Get your files - -## What is a BIOS? - -[What is a BIOS?](what-are-bios-files.md) tells you which consoles need one. - -## Install - -[Get your files](../which-pack.md) gives the command and the ZIP for your -setup. [First install, step by step](first-install.md) shows each step. - -## Check your folder - -[When a game does not start](troubleshooting.md#checking-what-you-installed) -shows how to check your BIOS folder. From 8361575be86b2239e16c457b34966f49d8d86540 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:42:19 +0200 Subject: [PATCH 2/3] docs: start pages say what the installer does The safety list names the save folder, every install command shows its Windows form, first install reads the last line of a run, the FAQ answers zip or unzip and the arcade version rule, and each page keeps one name. --- tests/test_doc_pages.py | 9 +++- tests/test_doc_surfaces.py | 5 +- tests/test_site_copy.py | 1 - tests/test_site_funnel.py | 13 +++-- wiki/faq.md | 96 ++++++++++++++++++------------------- wiki/first-install.md | 73 +++++++++++++++------------- wiki/glossary.md | 16 +++++-- wiki/legal.md | 13 +++-- wiki/technical-faq.md | 10 ++-- wiki/troubleshooting.md | 34 ++++++------- wiki/what-are-bios-files.md | 11 ++--- 11 files changed, 150 insertions(+), 131 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index cd208c02..bddc28bc 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -698,7 +698,14 @@ class FirstInstallWalkthrough(unittest.TestCase): step3 = _section(self.run, "The file index and the download") step4 = _section(self.run, "The end of the run") step5 = _section(self.run, "A later check") - self.assertIn(re.search(r"Done\. \d+ files installed", step4).group(0), self.text) + # First install names the three endings a run prints, and no count: + # a figure from one setup read as the size of every install. + source = (ROOT / "install.py").read_text(encoding="utf-8") + for ending in ("Done.", "Finished with errors.", "Nothing was installed."): + with self.subTest(ending=ending): + self.assertIn(f"`{ending}`", _section(self.text, "5. Read the last line")) + self.assertIn(f'"\\n{ending} ', source) + self.assertNotRegex(self.text, r"Done\. \d+ files") count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups() self.assertIn(f"0/{count} present", step3) self.assertIn(f"Downloading {count} files ({size})", step3) diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py index a565740c..02361b1d 100644 --- a/tests/test_doc_surfaces.py +++ b/tests/test_doc_surfaces.py @@ -186,7 +186,7 @@ class SlicesLeaveWithTheirRelease(unittest.TestCase): """Parts named `.zip.001` were cut by v2026.09.04, deleted since. A page explaining a layout no release serves buries the one the user has.""" - PAGES = ("README.md", "wiki/getting-started.md", "wiki/troubleshooting.md") + PAGES = ("README.md", "wiki/troubleshooting.md") def setUp(self): assets = [a for p in RECORD.get("packs", {}).values() for a in p["assets"]] @@ -236,7 +236,6 @@ class DestIsTheBiosFolder(unittest.TestCase): PAGES = ( "README.md", - "wiki/getting-started.md", "wiki/installer.md", "wiki/faq.md", "wiki/first-install.md", @@ -269,7 +268,7 @@ class ShownOutputIsPrintedOutput(unittest.TestCase): self.assertIn(literal, source) def test_samples_match_a_shape(self): - section = _section(_read("wiki/verification-modes.md"), "### Reading the output") + section = _section(_read("wiki/reading-reports.md"), "### Reading the output") blocks = re.findall(r"```\n(.*?)```", section, re.DOTALL) self.assertTrue(blocks) for block in blocks: diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py index eece3067..de54fd02 100644 --- a/tests/test_site_copy.py +++ b/tests/test_site_copy.py @@ -35,7 +35,6 @@ START_PAGES = { "wiki/troubleshooting.md": 300, "wiki/faq.md": 450, "wiki/glossary.md": 200, - "wiki/getting-started.md": 120, } # Level 1: the guide part of each setup page, above its Reference heading. SETUP_CEILING = 220 diff --git a/tests/test_site_funnel.py b/tests/test_site_funnel.py index bf2234a1..284de356 100644 --- a/tests/test_site_funnel.py +++ b/tests/test_site_funnel.py @@ -96,8 +96,7 @@ class EveryPlatformOnThePicker(unittest.TestCase): def test_every_wiki_page_is_in_the_navigation(self): """A wiki page is published as soon as it exists, but a page left out of the nav is reachable only by a link and MkDocs says so at info - level. getting-started.md is kept out on purpose: it only links the - pages that took its content, for the old links that point to it.""" + level.""" def targets(entries) -> list[str]: found = [] for entry in entries: @@ -109,7 +108,7 @@ class EveryPlatformOnThePicker(unittest.TestCase): return found nav = " ".join(targets(generate_site.generate_nav({}, {}, {}, {}))) - kept_out = {"getting-started.md"} + kept_out: set[str] = set() wiki = Path(__file__).resolve().parents[1] / "wiki" missing = sorted( p.name for p in wiki.glob("*.md") @@ -239,7 +238,13 @@ class GuidePagesEndOnANextStep(unittest.TestCase): path = REPO_ROOT / page with self.subTest(page=page): text = path.read_text(encoding="utf-8") - self.assertTrue(":::tip[Next]" in text or "## What comes next" in text) + if page.startswith("wiki/"): + # A plain list: an aside drew a coloured stripe and an icon. + self.assertIn("\n## Next steps\n", text) + self.assertNotIn(":::tip[Next]", text) + else: + self.assertTrue(":::tip[Next]" in text or "## Next steps" in text + or "## What comes next" in text) def test_the_platform_page_opens_on_its_guide(self): """The folder came after four counters and a metadata table, and was diff --git a/wiki/faq.md b/wiki/faq.md index 092a1cc6..0499bf38 100644 --- a/wiki/faq.md +++ b/wiki/faq.md @@ -1,10 +1,9 @@ --- level: start order: 50 -nav_title: Common questions --- -# FAQ +# Common questions ## Do I need BIOS files? @@ -14,36 +13,55 @@ many home computers. For NES or Super Nintendo cartridges, usually not. ## How do I know which BIOS I need? -Run the installer with `--dry-run`. It lists what it would install and changes +Run the command with `--dry-run`. It lists what it would install and changes nothing. + ```sh curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --dry-run ``` -Your setup's page also lists every file, console by console. +Your setup's page lists every file. + +## Do I need every file of a console? + +No. A console often has one BIOS per region. The ZIP holds them all. To keep +only yours, add your region, like `--region us`. + +## Zip or unzip? { #zip-or-unzip } + +Unzip the ZIP you downloaded once, into the folder your setup's page names. +Leave the ZIP files inside it as they are, like `neogeo.zip`. Arcade emulators +read them zipped. + +## Does a BIOS work on every setup? + +The same file usually works everywhere. Some setups also check the version and +report one they do not expect. Arcade files like `neogeo.zip` change with the +emulator. The command installs the version your setup expects. + +## Can I play without the BIOS? + +Sometimes. Some emulators carry a stand-in for it. Some games then glitch or +do not start. ## Is the command safe? { #is-piping-the-installer-into-a-shell-safe } -- It checks the installer's fingerprint before running it. If the download - does not match its expected fingerprint, it stops. -- It checks every file before saving it. -- It writes only in your BIOS folder. - -To read the script before you run it: - -```sh -curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh -o install.sh -less install.sh && sh install.sh -``` +- It checks the installer's fingerprint first. If it does not match its expected + fingerprint, it stops. +- It checks each file before saving it. +- It writes in your BIOS folder. For some emulators it also adds files to your + save folder. +- It never changes or deletes a game or a save. +- It keeps any file it replaces in a `retrobios-backup` folder, next to your + BIOS folder. `--restore` puts it back + ([details](installer.md#files-you-already-had)). +Read the script first: [install.sh](https://github.com/Abdess/retrobios/blob/main/install.sh), +[install.ps1](https://github.com/Abdess/retrobios/blob/main/install.ps1) and +[install.py](https://github.com/Abdess/retrobios/blob/main/install.py). Details: [How the installer checks itself](installer.md#what-the-bootstrap-does). -## Will it change my other files? - -No. It never touches games or saves, or a file it does not know. It replaces a -BIOS file with the wrong contents, and keeps yours. - ## Windows or my antivirus blocks the command Download the ZIP of your setup instead, from [Get your files](../which-pack.md). @@ -51,35 +69,22 @@ It holds the same files. ## Can I run it again? -Yes. That is how you update. It downloads only what is missing or wrong. - -## Can I undo it? - -Yes. It keeps every file it replaces in a `retrobios-backup` folder, beside -your BIOS folder. The end of the run prints the line that puts them back: the -same command with `--restore` and that folder. - -Details: [Files you already had](installer.md#files-you-already-had). +Yes, to update. It downloads only what is missing or wrong. ## Fill an SD card from a computer? -Yes. Name your setup and the BIOS folder on the card: +Yes. Name your setup and the BIOS folder on the card. On Windows, `E:` stands +for the card. + ```sh curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh \ | sh -s -- --platform retroarch --dest /path/to/sdcard/retroarch/cores/system ``` -On Windows the line is different. Details: [Passing options](installer.md#passing-options). - -## Does a BIOS work on every setup? - -Yes. Only the folder changes, and sometimes the name. - ## Why is a file missing from the ZIP? -Some files come with the emulator when you install it. The ZIP leaves those -out. +Some files come with the emulator. The ZIP leaves those out. ## Required or optional? @@ -93,19 +98,10 @@ Most of the time, a BIOS file is missing or is the wrong version. ## Is this legal? -Laws on copying console firmware differ by country. The question is not -settled, and nothing here is legal advice. Details: [The project's legal position](legal.md). +Laws differ by country, and nothing here is legal advice. Details, and how to +ask for a file to be removed: [Is it legal?](legal.md) -To ask for a file to be removed, [open an issue](https://github.com/Abdess/retrobios/issues) -and name the file. A file is removed when the owner of its rights asks. +## Next steps -## Why keep recent consoles too? - -Files disappear when stores close, like the 3DS and Wii U shops in 2023. We keep -them while they can still be found. - -:::tip[Next] - [Get your files](../which-pack.md) - [Words used on this site](glossary.md) -::: - diff --git a/wiki/first-install.md b/wiki/first-install.md index fb31ea97..a7b0bca7 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -1,10 +1,9 @@ --- level: start order: 30 -nav_title: First install, step by step --- -# First install +# First install, step by step From an empty BIOS folder to games that start. @@ -20,34 +19,43 @@ system, then press Enter. Open the Start menu. Type **PowerShell** and open it. -```powershell +```powershell title="PowerShell" irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex ``` - + Open **Terminal**. It is in Applications, then Utilities. -```sh +```sh title="Terminal" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh ``` - + -On a Steam Deck, switch to Desktop Mode and open **Konsole**. +Open a terminal. -```sh +```sh title="Terminal" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh ``` - + + +Switch to Desktop Mode and open **Konsole**. + +```sh title="Terminal" +curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh +``` + + + Install **Termux**, a terminal app. Run these three lines in it. -```sh +```sh title="Terminal" pkg install python termux-setup-storage curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh @@ -56,22 +64,20 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | - -On Batocera, Recalbox and other game systems, follow +On Batocera, Recalbox and similar setups, follow [your setup's page](../which-pack.md). - - ## 2. Check the folder it found It shows your setup and its BIOS folder: ``` - Found Batocera at /userdata/bios + Found at ``` - **Right folder?** Go on. -- **Wrong folder?** Press Ctrl+C to stop. Then see [If it stops](#if-it-stops). +- **Wrong folder?** Press Ctrl+C. Run the command again with `--dest` and your + folder. ## 3. Choose what to install @@ -86,22 +92,25 @@ It shows your setup and its BIOS folder: ## 4. Wait -It shows one line per file. You can stop with Ctrl+C at any time. Run the -command again to carry on. +You can stop with Ctrl+C at any time. Run the command again to carry on. -## 5. Look for "Done." +## 5. Read the last line -``` -Done. 14481 files installed, 0 files already up to date. -Location: /userdata/bios -``` +- **`Done.`** All files are in place. +- **`Finished with errors.`** Some files failed. Run the same command again. + It retries only those. +- **`Nothing was installed.`** The lines above it say why. See + [If it stops](#if-it-stops). -`Done.` means it worked. It may list files it could not install. +## What the command does + + ## Check it later Add `--check` to the same command. It changes nothing. + ```sh curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --check ``` @@ -110,11 +119,8 @@ All good when you see `0 wrong hash`. ## Undo -It keeps every file it replaces in a `retrobios-backup` folder, beside your -BIOS folder. Nothing is deleted. - -To put your files back, run the command with `--restore` and that folder. The -end of the run prints the exact line. +It keeps every file it replaces in a `retrobios-backup` folder, next to your +BIOS folder. The end of the run prints the line that puts them back. ## If it stops @@ -141,8 +147,8 @@ The download was cut short. Nothing was changed. Run the command again. ### Cannot create ... -You do not have the right to write there. On Batocera, Recalbox and ROCKNIX, -put `sudo` and a space before the command. It then runs as the administrator. +You do not have the right to write there. Do what the message says. It names +what to run. ### Not enough space on the drive holding ... @@ -153,9 +159,8 @@ drive. See [When a game does not start](troubleshooting.md). -:::tip[Next] +## Next steps + - [Your setup's page](../which-pack.md): its folder and how it checks files - [When a game does not start](troubleshooting.md) - Details: [Reading the installer's output, line by line](installer.md#reading-a-run) -::: - diff --git a/wiki/glossary.md b/wiki/glossary.md index e19275e4..75c966e7 100644 --- a/wiki/glossary.md +++ b/wiki/glossary.md @@ -1,7 +1,6 @@ --- level: start order: 60 -nav_title: Glossary --- # Words used on this site @@ -22,6 +21,12 @@ nav_title: Glossary **BIOS folder** : The folder where your setup looks for BIOS files. +**Pack** +: The ZIP file with every BIOS file for one setup. + +**Platform** +: A setup, as the project's data calls it. + **ZIP** : One file that holds many files. You unzip it to get them out. @@ -34,6 +39,9 @@ nav_title: Glossary **Hash** : A fingerprint of a file's contents. A different file gets a different hash. +**MD5** / **SHA-1** +: Two kinds of hash, a file's fingerprint. Setups that check files list one. + **Region** : Where a console was sold: Japan, North America, Europe. @@ -43,9 +51,9 @@ nav_title: Glossary **Game data** : Files from your own copy of a PC game, like Doom. Not a BIOS. -:::tip[Next] +## Next steps + - [What is a BIOS?](what-are-bios-files.md) - [Get your files](../which-pack.md) -- Reference: [Terms of the reference pages](reference-terms.md) -::: +- For contributors: [Terms of the reference pages](reference-terms.md) diff --git a/wiki/legal.md b/wiki/legal.md index f2524f56..638acafc 100644 --- a/wiki/legal.md +++ b/wiki/legal.md @@ -1,10 +1,9 @@ --- -level: explore -order: 40 -nav_title: "Is it legal?" +level: start +order: 55 --- -# The project's legal position +# Is it legal? Redistributing firmware is not settled law. Below is the reasoning the project acts on, with the strength of each argument, so anyone can weigh it. None of it is legal advice, and none of it has been tested in court. @@ -41,8 +40,8 @@ This reflects the project's good-faith understanding, not legal advice. A rights holder, or anyone acting for one, can [open an issue](https://github.com/Abdess/retrobios/issues) identifying the file by path or hash. A file whose removal is requested by its rights holder is removed from the repository and from the next release. No formal notice is required and none will be demanded. State the file and the basis of the claim. The same channel is open to anyone else with a concern about a specific file. -:::tip[Next] -- [FAQ](faq.md) +## Next steps + +- [Common questions](faq.md) - [What is a BIOS?](what-are-bios-files.md) -::: diff --git a/wiki/technical-faq.md b/wiki/technical-faq.md index 9b9adaf6..6a6b7002 100644 --- a/wiki/technical-faq.md +++ b/wiki/technical-faq.md @@ -8,8 +8,8 @@ order: 120 Answers to "how do you know?" questions: how a file is identified, why one BIOS has several fingerprints, why some files are not BIOS, and which MAME version an arcade set belongs to. Each answer gives a real file as its example, so you can -check it on the system pages. Questions asked before installing are in the -[FAQ](faq.md). +check it on the system pages. Questions asked before installing are in +[Common questions](faq.md). ## What's a hash/checksum? @@ -143,8 +143,8 @@ Running `verify.py --emulator --verbose` shows the emulator-level ground It reaches only as far as the profiles do. Files no profile states a value for are platform-only, and nothing can be said about their content. The footer of the platform report gives that ratio as `Ground truth: N/M files have emulator validation`. -:::tip[Next] -- [Reference index](index.md), every reference page in one list +## Next steps + +- [Reference](index.md), every reference page in one list - For contributors: [How to contribute](../contributing.md) -::: diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index 966f2df4..7d14c4e1 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -14,6 +14,7 @@ find it. Run the command with `--check`. It changes nothing. + ```sh curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --check ``` @@ -24,16 +25,15 @@ Read the count line: 1/109 present (0 verified, 1 wrong hash) ``` -- **`wrong hash`**: a file is there, but its contents are wrong. It may be for - another region or model, or damaged. +- **`wrong hash`**: a file is there with the wrong contents: another region + or model, or damaged. - **Fewer present than the total**: some files are missing. - **`0 wrong hash` and all present**: your files are fine. Go to step 3. ## 2. Fix the files Run the same command without `--check`. It adds the missing files and replaces -the wrong ones. It keeps the old ones in `retrobios-backup`, beside your BIOS -folder. +the wrong ones, keeping the old ones. ## 3. Check the folder and the names @@ -41,8 +41,10 @@ folder. [Your setup's page](../which-pack.md) names it. - **Is the name exact?** `scph5501.bin` and `SCPH-5501.BIN` are two different names. On Linux, capital letters count too. -- **Unzipped by hand on Linux?** Use the command. Some emulators there need a - lower-case name the ZIP lacks. +- **A `bios` folder inside your BIOS folder?** That ZIP holds its own top + folder. Move its files up one level. +- **Unzipped by hand on Linux?** Run the command once. It adds the names that + differ only by capital letters, which some ZIPs leave out. - **A ZIP in parts will not open?** Download every part, like `Pack.part1of2.zip` and `Pack.part2of2.zip`. Unzip each one into the same folder. @@ -51,6 +53,7 @@ folder. Add `--system` and its name: + ```sh curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --check --system sony-playstation --verbose ``` @@ -60,26 +63,25 @@ console name. ## My setup says UNTESTED -Batocera shows `UNTESTED` for a file whose contents it does not expect. Run -the command without `--check` to replace it. +Batocera shows `UNTESTED` for a file with unexpected contents. Run the command +without `--check` to replace it. ## If the command fails { #installation-script-fails } - **Network error:** check that github.com opens, then try again. -- **Permission denied:** on Batocera, Recalbox and ROCKNIX, put `sudo` and a - space before the command. It then runs as the administrator. -- **No setup found:** name it yourself, with your BIOS folder: +- **Permission denied:** do what the message says. It names what to run. +- **No setup found:** pick it from the list. Or name it and its folder: + ```sh curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --platform retroarch --dest ~/.config/retroarch/system ``` -Other messages: Details: [When something goes wrong](installer.md#when-something-goes-wrong). +Other messages: [When something goes wrong](installer.md#when-something-goes-wrong). -:::tip[Next] -- [Your setup's page](../which-pack.md): its folder and how it checks files -- [FAQ](faq.md) -::: +## Next steps +- [Your setup's page](../which-pack.md): its folder and how it checks files +- [Common questions](faq.md) diff --git a/wiki/what-are-bios-files.md b/wiki/what-are-bios-files.md index 87427f84..c0070d53 100644 --- a/wiki/what-are-bios-files.md +++ b/wiki/what-are-bios-files.md @@ -1,7 +1,6 @@ --- level: start order: 10 -nav_title: What BIOS files are --- # What is a BIOS? @@ -18,8 +17,8 @@ a copy of the BIOS. That copy is a file: a **BIOS file**. ## How it works -1. You pick a game in your game menu. -2. The menu starts the emulator. +1. You pick a game in your setup's menu. +2. The setup starts the emulator. 3. The emulator opens the BIOS file in your **BIOS folder**. 4. The game starts. @@ -57,10 +56,10 @@ then glitch or do not start. The real file works best, so we include it. ## Is it legal? -Laws differ by country. [Read the project's position](faq.md#is-this-legal). +Laws differ by country. [Is it legal?](legal.md) + +## Next steps -:::tip[Next] - [Get your files](../which-pack.md) - [Words used on this site](glossary.md) -::: From 2927c4973488b7d0fbc9dde47be3e57f193bbca2 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:42:19 +0200 Subject: [PATCH 3/3] docs: split project reports, pin checker links Reading this project's own reports gets its own page. Checker code is linked at the revision each platform list was read from. Installer moves to Your setup, legal to Start here, build internals to Contribute, and Reference pages carry a group. --- tests/test_site_content_data.py | 28 +-- tests/test_site_r2_wiki.py | 372 ++++++++++++++++++++++++++++++++ wiki/advanced-usage.md | 4 +- wiki/architecture.md | 4 +- wiki/community-tools.md | 1 + wiki/index.md | 43 ++-- wiki/installer.md | 15 +- wiki/profiling.md | 1 - wiki/reading-reports.md | 152 +++++++++++++ wiki/reference-terms.md | 11 +- wiki/testing-guide.md | 12 +- wiki/tools.md | 4 +- wiki/verification-modes.md | 208 ++++-------------- 13 files changed, 629 insertions(+), 226 deletions(-) create mode 100644 tests/test_site_r2_wiki.py create mode 100644 wiki/reading-reports.md diff --git a/tests/test_site_content_data.py b/tests/test_site_content_data.py index 295ea1ba..a9c0f2d4 100644 --- a/tests/test_site_content_data.py +++ b/tests/test_site_content_data.py @@ -153,23 +153,24 @@ class NavFromFrontMatter(unittest.TestCase): def test_the_repository_nav_matches_the_pages(self): tabs = self._nav(WIKI) - self.assertEqual(tabs["Explore"][-2:], [ - {"All installer options": "wiki/installer.md"}, - {"Is it legal?": "wiki/legal.md"}, - ]) + self.assertEqual(tabs["Explore"][0], "explore.md") + self.assertEqual([next(iter(e)) for e in tabs["Explore"][1:]], ["Systems", "Emulators"]) + self.assertEqual(tabs["Your setup"][-1], {"All installer options": "wiki/installer.md"}) + start = [e if isinstance(e, str) else next(iter(e)) for e in tabs["Start here"]] + self.assertEqual(start.index("Is it legal?"), start.index("Common questions") + 1) self.assertEqual(tabs["Reference"][0], "wiki/index.md") def test_a_new_page_lands_in_its_tab_at_its_order(self): with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: wiki = _wiki_copy(tmp) (wiki / "save-files.md").write_text( - "---\nlevel: explore\norder: 35\n---\n\n# Save files\n\nText.\n", + "---\nlevel: start\norder: 45\n---\n\n# Save files\n\nText.\n", encoding="utf-8") - explore = self._nav(wiki)["Explore"] - targets = [e if isinstance(e, str) else next(iter(e.values())) for e in explore] + start = self._nav(wiki)["Start here"] + targets = [e if isinstance(e, str) else next(iter(e.values())) for e in start] self.assertEqual(targets.index("wiki/save-files.md"), - targets.index("wiki/installer.md") + 1) - self.assertIn({"Save files": "wiki/save-files.md"}, explore) + targets.index("wiki/troubleshooting.md") + 1) + self.assertIn({"Save files": "wiki/save-files.md"}, start) def test_a_page_without_a_level_fails_by_name(self): with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: @@ -200,9 +201,12 @@ class NavFromFrontMatter(unittest.TestCase): self._nav(wiki) def test_nav_false_keeps_a_page_out(self): - tabs = self._nav(WIKI) - flat = repr(tabs) - self.assertNotIn("getting-started.md", flat) + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + wiki = _wiki_copy(tmp) + (wiki / "hidden.md").write_text( + "---\nlevel: start\nnav: false\n---\n\n# Hidden\n", encoding="utf-8") + flat = repr(self._nav(wiki)) + self.assertNotIn("hidden.md", flat) def test_every_wiki_page_states_its_place(self): for page in sorted(WIKI.glob("*.md")): diff --git a/tests/test_site_r2_wiki.py b/tests/test_site_r2_wiki.py new file mode 100644 index 00000000..361114bd --- /dev/null +++ b/tests/test_site_r2_wiki.py @@ -0,0 +1,372 @@ +"""Wiki copy and the diagrams module (round 2, package 5). + +Each class names the finding it holds: a safety line the code contradicted, +install commands with no Windows form, a run's end described wrongly, pages +with two names, an FAQ answer the setups' own checks contradict, a setup +count given two ways, unreadable diagram text, and example hashes that +answered a hash search in place of the file card. +""" + +from __future__ import annotations + +import json +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")) +sys.path.insert(0, str(REPO_ROOT)) + +import sitediagrams # noqa: E402 +from siteguides import INSTALL_BASE # noqa: E402 + +from tests import sitepages # noqa: E402 + +WIKI = REPO_ROOT / "wiki" +FENCED = re.compile(r"^```.*?^```", re.S | re.M) + + +def _front(text: str) -> dict: + match = re.match(r"---\n(.*?)\n---\n", text, re.S) + return yaml.safe_load(match.group(1)) if match else {} + + +def _h1(text: str) -> str: + match = re.search(r"^# (.+?)(?:\s*\{[^}]*\})?\s*$", FENCED.sub("", text), re.M) + return match.group(1) if match else "" + + +def _section(text: str, heading: str) -> str: + start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text) + if start is None: + raise AssertionError(f"no section {heading!r}") + rest = text[start.end():] + end = re.search(r"(?m)^#{1,2} ", FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest)) + return rest[: end.start()] if end else rest + + +def _wiki(name: str) -> str: + return (WIKI / name).read_text(encoding="utf-8") + + +def _built(name: str) -> str: + sitepages.require() + return sitepages.page(name).read_text(encoding="utf-8") + + +START_PAGES = ("what-are-bios-files.md", "first-install.md", "troubleshooting.md", + "faq.md", "glossary.md", "legal.md") + + +class SafetyListMatchesTheInstaller(unittest.TestCase): + """C1: "It writes only in your BIOS folder", while install.py writes the + entries marked root: save into the setup's save folder.""" + + def test_the_faq_names_the_save_folder_while_a_manifest_writes_there(self): + saved = [ + path.name for path in sorted((REPO_ROOT / "install").glob("*.json")) + if any(entry.get("root") == "save" + for entry in json.loads(path.read_text(encoding="utf-8")).get("files", [])) + ] + section = _section(_wiki("faq.md"), "Is the command safe?") + if saved: + self.assertNotIn("only in your BIOS folder", section) + self.assertIn("save folder", section) + self.assertIn("retrobios-backup", section) + self.assertIn("--restore", section) + + +class InstallCommandsHaveEveryForm(unittest.TestCase): + """N7, B3, C6: troubleshooting and the FAQ gave only `curl ... | sh`. + Every install command on a Start here page is marked for the system tabs, + and the built page shows the PowerShell form beside it.""" + + def test_every_install_command_on_a_start_page_is_marked(self): + for name in START_PAGES: + text = _wiki(name) + with self.subTest(page=name): + for match in re.finditer(r"(?m)^```(?:sh|bash)\n(.*?)\n```", text, re.S): + if "install.sh" not in match.group(1) or "|" not in match.group(1): + continue + before = text[: match.start()].rstrip("\n").rsplit("\n", 1)[-1] + self.assertRegex(before, r"\n```sh\n" + f"curl -fsSL {INSTALL_BASE}/install.sh \\\n | sh -s -- --platform retroarch --dest /a/b\n```\n" + ) + text = sitediagrams.expand_commands(page) + windows, rest = text.split('', 1) + self.assertIn("--dest E:\\x", windows) + self.assertNotIn("/a/b", windows) + self.assertIn("| sh -s -- --platform retroarch --dest /a/b", rest) + + def test_a_marked_block_without_an_install_command_fails_the_build(self): + with self.assertRaises(ValueError): + sitediagrams.expand_commands("\n```sh\nls -l\n```\n") + + +class FirstInstallEnds(unittest.TestCase): + """C7, B15: the page said `Done.` may list failures, and sent a wrong + folder to a section with no answer for it.""" + + def test_the_wrong_folder_answer_is_dest(self): + section = _section(_wiki("first-install.md"), "2. Check the folder it found") + self.assertIn("--dest", section) + self.assertIn("", section) + self.assertNotIn("Batocera", section) + + def test_the_diagram_follows_the_steps(self): + text = _wiki("first-install.md") + self.assertLess(text.index("## 5. Read the last line"), text.index("")) + + +class OneNamePerPage(unittest.TestCase): + """N11, C4, V22: "Common questions" in the menu opened "FAQ".""" + + def test_no_wiki_page_names_itself_twice(self): + for path in sorted(WIKI.glob("*.md")): + text = path.read_text(encoding="utf-8") + with self.subTest(page=path.name): + self.assertNotIn("nav_title", _front(text)) + + def test_links_to_a_renamed_page_use_its_heading(self): + names = {path.name: _h1(path.read_text(encoding="utf-8")) for path in WIKI.glob("*.md")} + stale = {"faq.md": "FAQ", "glossary.md": "Glossary", "legal.md": "The project's legal position"} + for path in sorted(WIKI.glob("*.md")): + for text, target in re.findall(r"\[([^\]]+)\]\(([a-z-]+\.md)\)", path.read_text(encoding="utf-8")): + if target in stale and target in names: + with self.subTest(page=path.name, link=text): + self.assertNotEqual(text, stale[target]) + + +class ArcadeFilesDependOnTheSetup(unittest.TestCase): + """C11, C02: "Does a BIOS work on every setup? Yes." Platforms check an + MD5 and refuse another revision, and neogeo.zip differs by setup.""" + + def test_the_answer_is_not_a_plain_yes(self): + section = _section(_wiki("faq.md"), "Does a BIOS work on every setup?").strip() + self.assertFalse(section.startswith("Yes")) + self.assertIn("neogeo.zip", section) + + def test_the_setups_do_install_different_neogeo_zips(self): + digests = set() + for path in (REPO_ROOT / "install").glob("*.json"): + for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []): + if str(entry.get("dest", "")).lower().endswith("/neogeo.zip") or entry.get("dest") == "neogeo.zip": + digests.add(entry.get("sha1")) + self.assertGreater(len(digests), 1) + + def test_zip_or_unzip_is_answered(self): + section = _section(_wiki("faq.md"), "Zip or unzip?") + self.assertIn("neogeo.zip", section) + + +class VerificationModesSplit(unittest.TestCase): + """C12, N24, F5: the page opened on "Before a game starts", ran 37 + entries of contents, and cited checker code as plain text.""" + + def test_opening(self): + text = _wiki("verification-modes.md") + self.assertNotIn("Before a game starts", text) + self.assertIn("Most setups can show which BIOS files are missing.", text) + + def test_the_project_reports_have_their_own_page(self): + modes = _wiki("verification-modes.md") + reports = _wiki("reading-reports.md") + for heading in ("## Hash mismatch / UNTESTED", "## verify.py reports errors", "### Reading the output"): + with self.subTest(heading=heading): + self.assertNotIn(heading, modes) + self.assertIn(heading, reports) + self.assertEqual(_front(reports).get("level"), "reference") + + def test_checker_links_are_pinned_to_the_revision_the_platform_names(self): + named = " ".join( + f"{data.get('version', '')} {data.get('source', '')}" + for data in (yaml.safe_load(p.read_text(encoding="utf-8")) or {} + for p in (REPO_ROOT / "platforms").glob("*.yml")) + if isinstance(data, dict) + ) + links = re.findall(r"\((https://(?:github\.com|gitlab\.com)/[^)\s]+)\)", _wiki("verification-modes.md")) + pinned = [url for url in links if re.search(r"/-?/?blob/", url)] + self.assertGreaterEqual(len(pinned), 10) + for url in pinned: + revision = re.search(r"/blob/([^/]+)/", url).group(1) + with self.subTest(url=url): + self.assertNotIn(revision, ("main", "master", "HEAD")) + self.assertIn(revision, named) + + +class SetupCount(unittest.TestCase): + """C2: the home page said 15 more, the diagram 14 more, from two counts.""" + + def test_one_count(self): + from common import list_registered_platforms + + self.assertEqual(len(sitediagrams.setups()), + len(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))) + + def test_every_rendered_count_agrees(self): + """Each "RetroArch, Batocera, EmuDeck and N more" names three setups.""" + found = set() + for path in [*sitepages.pages()]: + text = path.read_text(encoding="utf-8") + found.update(int(n) for n in re.findall(r"(?:RetroArch|EmuDeck|Batocera) and (\d+) more", text)) + found.update(int(n) for n in re.findall( + r"and (\d+) more", sitediagrams.og_card_svg("home", b"png"))) + self.assertTrue(found) + self.assertEqual(found, {len(sitediagrams.setups()) - 3}) + + +class Vocabulary(unittest.TestCase): + """C5: one thing called device, setup, frontend, platform and game menu.""" + + def test_the_glossary_defines_the_words_the_reference_uses(self): + terms = {term for term in re.findall(r"^\*\*([^*]+)\*\*", _wiki("glossary.md"), re.M)} + terms |= set(re.findall(r"\*\*([^*]+)\*\*", _wiki("glossary.md"))) + for word in ("Pack", "Platform", "MD5", "SHA-1", "Setup"): + with self.subTest(word=word): + self.assertIn(word, terms) + + def test_start_pages_and_the_first_diagram_say_setup(self): + for name in START_PAGES[:5]: + text = FENCED.sub("", _wiki(name)).lower() + with self.subTest(page=name): + self.assertNotRegex(text, r"\bfrontends?\b") + self.assertNotIn("game menu", text) + for figure in ("bios-chain", "install-flow"): + with self.subTest(figure=figure): + self.assertNotIn("rontend", sitediagrams.figure_html(figure)) + + +class NavigationPlaces(unittest.TestCase): + """N14, N15, N16, T5: installer and legal in Explore, build internals in + Reference, and a legacy Getting started page in no tab.""" + + def test_front_matter(self): + expected = { + "installer.md": "setup", "legal.md": "start", + "architecture.md": "contribute", "tools.md": "contribute", + "advanced-usage.md": "contribute", "reference-terms.md": "contribute", + } + for name, level in expected.items(): + with self.subTest(page=name): + self.assertEqual(_front(_wiki(name))["level"], level) + self.assertEqual(_front(_wiki("legal.md"))["order"], _front(_wiki("faq.md"))["order"] + 5) + self.assertEqual(_front(_wiki("verification-modes.md"))["group"], "Checking files") + self.assertEqual(_front(_wiki("community-tools.md"))["group"], "Data") + self.assertFalse((WIKI / "getting-started.md").exists()) + + def test_no_next_aside_is_left(self): + for path in sorted(WIKI.glob("*.md")): + with self.subTest(page=path.name): + self.assertNotIn(":::tip[Next]", path.read_text(encoding="utf-8")) + + +class TroubleshootingLines(unittest.TestCase): + """C22: "Details: When something goes wrong", a sudo line that is the + installer's to give, and an unclear Linux line.""" + + def test_lines(self): + text = _wiki("troubleshooting.md") + self.assertNotIn("Details: [When something goes wrong]", text) + self.assertIn("Other messages: [When something goes wrong]", text) + self.assertIn("Run the command once.", text) + for name in ("troubleshooting.md", "first-install.md"): + with self.subTest(page=name): + self.assertNotIn("sudo", _wiki(name)) + self.assertRegex(" ".join(_wiki(name).split()), r"[Dd]o what the message says") + + def test_the_installer_names_what_to_run(self): + source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + advice = source[source.index("def _write_advice"):source.index("def _plural")] + self.assertIn("_INVOCATION.command(plat, path, elevated=True)", advice) + + +class DiagramText(unittest.TestCase): + """V17: 11 px text in fixed-height boxes, paths broken mid-token.""" + + def test_text_reads_at_14_px_in_the_column(self): + self.assertGreaterEqual(sitediagrams.TEXT_SIZE * sitediagrams.COLUMN / sitediagrams.WIDE, 14) + + def test_no_box_holds_more_than_three_lines(self): + wide = sitediagrams.WIDE - 2 + layouts = { # the box width of each wide layout: flow, grid of two, columns + "bios-chain": (sitediagrams._bios_chain_boxes(), (wide - 26 * 3) / 4), + "install-flow": (sitediagrams._install_boxes(), (wide - 18) / 2), + "verification-modes": (sitediagrams._mode_boxes(), (wide - 16 * 2) / 3), + } + for name, (boxes, box_width) in layouts.items(): + for width in (sitediagrams.NARROW - 2, box_width): + for box in boxes: + with self.subTest(figure=name, box=box.title, width=width): + self.assertLessEqual(len(sitediagrams.box_lines(box, width)[1]), + sitediagrams.MAX_BOX_LINES) + + def test_a_path_is_mono_on_its_own_line(self): + html = sitediagrams.figure_html("bios-chain") + self.assertRegex(html, r']*style="font-family: var\(--sl-font-mono[^>]*>/userdata/bios/') + + def test_boxes_take_the_height_of_their_lines(self): + width, _height, body = sitediagrams.flow(sitediagrams._bios_chain_boxes(), False, sitediagrams.WIDE) + heights = {float(h) for h in re.findall(r']*height="([\d.]+)"[^>]*class="rb-d-box', "".join(body))} + self.assertGreater(len(heights), 1) + + +class ExampleHashesStayOutOfSearch(unittest.TestCase): + """F4: the scph5501 MD5 typed in search found four documentation pages + and not the file card.""" + + def test_wiki_hashes_are_excluded(self): + text = _built("wiki/verification-modes.md") + for match in re.finditer(r"\b[0-9a-f]{32}\b", text): + line_start = text.rfind("\n", 0, match.start()) + context = text[line_start: match.end() + 10] + with self.subTest(hash=match.group(0)): + self.assertIn("data-pagefind-ignore", context) + + def test_a_fenced_hash_is_wrapped(self): + out = sitediagrams.hide_hashes("```sh\nx 490f666e1afb15b7362b406ed1cea246\n```\n") + self.assertTrue(out.startswith("
\n\n```sh")) + + def test_only_wiki_pages_are_touched(self): + page = "Hash `490f666e1afb15b7362b406ed1cea246`.\n" + self.assertEqual(sitediagrams.prepare_page(page, Path("systems/sony.md")), page) + self.assertIn("data-pagefind-ignore", sitediagrams.prepare_page(page, Path("wiki/x.md"))) + + +class InstallerOptionsAtAGlance(unittest.TestCase): + """C23: a 9,000-word page with no table at the top. The table is read + from the parser, so it lists what --help lists.""" + + def test_every_option_of_the_parser_has_a_row(self): + source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + declared = set(re.findall(r'parser\.add_argument\(\s*"(--[a-z-]+)"', source)) + listed = {usage.split()[0].rstrip(",") for usage, _ in sitediagrams.installer_options()} + self.assertEqual(declared | {"--help"}, listed) + + def test_the_built_page_opens_on_the_table(self): + text = _built("wiki/installer.md") + table = text.index("| Option | What it does | More |") + self.assertLess(table, text.index("## One line")) + self.assertIn("| `--check` |", text) + self.assertNotIn(" + ## One line ```bash @@ -810,7 +817,7 @@ what it must hash to. Entries the collection cannot satisfy are not silently dropped: they travel in `omitted_files` with a reason, and the run prints the count before downloading anything. `not_found` means no file in the collection matches the entry, which -is what the [gap analysis](../gaps.md) tracks. +is what the gap analysis tracks (Reference: [What the packs do not hold](../gaps.md)). Some cores read a whole tree of data rather than a few files: Dolphin's `Sys/`, PPSSPP's assets, blueMSX's machines. These are not files of the collection, so @@ -950,7 +957,7 @@ no event at all. A program reads a missing `end` as that case. | Variable | Effect | |----------|--------| -| `RETROBIOS_REF` | Branch or tag the file list and the files committed to the repository are read from. Defaults to `main`. Files the manifest marks `release_asset` always come from the `large-files` release, whatever the ref, and that release replaces an asset in place when it is rebuilt. Each release deletes the previous release tags ([release process](release-process.md#cutting-a-release)), so a tag holds a file list until the next release | +| `RETROBIOS_REF` | Branch or tag the file list and the files committed to the repository are read from. Defaults to `main`. Files the manifest marks `release_asset` always come from the `large-files` release, whatever the ref, and that release replaces an asset in place when it is rebuilt. Each release deletes the previous release tags, so a tag holds a file list until the next release. For contributors: [release process](release-process.md#cutting-a-release) | | `RETROBIOS_BASE_URL` | Base URL for the manifest and the files it declares. HTTPS only, plain HTTP allowed to loopback for end-to-end tests. Whoever controls this base controls the expected hashes, hence the check. A file list that cannot be fetched names the address, and this variable when it is set | | `RETROBIOS_OS` | Names the host outright (`linux`, `wsl`, `windows`, `darwin`, `android`) instead of detecting it | | `EXTERNAL_STORAGE` | Android only, the shared storage root RetroArch builds its default directories from. Defaults to `/storage/emulated/0` | diff --git a/wiki/profiling.md b/wiki/profiling.md index 7b976038..d37add25 100644 --- a/wiki/profiling.md +++ b/wiki/profiling.md @@ -1,7 +1,6 @@ --- level: contribute order: 10 -nav_title: Profiling an emulator --- # Profiling guide diff --git a/wiki/reading-reports.md b/wiki/reading-reports.md new file mode 100644 index 00000000..8f202002 --- /dev/null +++ b/wiki/reading-reports.md @@ -0,0 +1,152 @@ +--- +level: reference +order: 15 +group: Checking files +--- + +# Reading this project's own reports + +These sections are about this repository's own tools, run from a clone. +`scripts/verify.py` checks the collection in `bios/` against each platform, never +your BIOS folder: for that, use the installer's `--check` +([When a game does not start](troubleshooting.md)). How each platform checks a +file is on [How each platform checks files](verification-modes.md). + +## Hash mismatch / UNTESTED + +`verify.py` reports `UNTESTED` for a file of the collection that was hashed +and does not match what the platform declares. The line gives the reason: + +``` + UNTESTED (required): nintendo-ds/dsifirmware.bin: declared hash contradicted by the local file +``` + +The system page on the site lists every known version of the file with its +expected MD5 and SHA1. + +### Existence-mode platforms never report UNTESTED + +On RetroArch, Lakka and RetroPie, the platform check only asks whether the file +is there, so the only outcomes are OK and MISSING. `verify.py` adds the emulator +ground truth from the profiles and flags a `DISCREPANCY` when a file passes the +platform check but fails the emulator's own size or hash validation. + +### The .variants/ directory + +When multiple versions of the same BIOS exist (different revisions, regions, or +dumps), the primary version lives in the main directory and alternatives live in +`.variants/`. `verify.py` checks the primary file first, then falls back to +variants when resolving by hash. + +A file that matches a variant hash but not the primary works. It is not the +version the emulator tries first, and some cores accept several versions. + +## Pack is missing files + +A generated pack doesn't contain all the files you expected. + +### Severity levels + +`verify.py` assigns a severity to each issue, counted under `severity_counts` +in its `--json` output. Not all missing files are equally important: + +| Severity | Meaning | Action needed | +|----------|---------|---------------| +| CRITICAL | Required file missing on an MD5 or SHA1 platform | Must fix. Core won't function. | +| WARNING | Optional file missing on an MD5 or SHA1 platform, hash mismatch (UNTESTED) on any hash platform, or a required file missing on an existence platform | Core works but with reduced functionality, or runs on content nobody verified. | +| INFO | Optional file missing on an existence-only platform, or a missing file the core covers with an HLE fallback | Core works fine, BIOS improves accuracy. | +| OK | File present and verified | No action needed. | + +The full mapping, including how `hle_fallback` overrides the platform mode, is +in the [severity matrix](verification-modes.md#severity-matrix). + +Focus on CRITICAL issues first. WARNING files improve the experience but aren't +strictly necessary. INFO files are nice to have. + +### Large files (release assets) + +Files like PS3UPDAT.PUP, PSVUPDAT.PUP, and Switch firmware are too large for the +git repository. They are stored as GitHub release assets under the `large-files` +release and downloaded at build time. + +If a pack build fails to include these, check your network connection. In offline +mode (`--offline`), large files are only included if already cached locally in +`.cache/large/`. + +### Data directories + +Some cores need entire directory trees rather than individual files (e.g. Dolphin's +`Sys/` directory, PPSSPP's `assets/`). These are fetched by `refresh_data_dirs.py` +from upstream repositories. + +In offline mode, data directories are only included if already cached in `data/`. +Run `python scripts/refresh_data_dirs.py` to fetch them. + +## verify.py reports errors + +How to read and interpret `verify.py` output. + +### Status codes + +| Status | Meaning | +|--------|---------| +| `OK` | File present, hash matches (or existence check passed) | +| `UNTESTED` | File present, hash not confirmed against the expected value | +| `MISSING` | File not found in the repository | +| `DISCREPANCY` | The platform check passes, an emulator's own check fails | + +### Reading the output + +The first line counts the platform's files and names its check. Under it come +the entries that need attention, one per line as `STATUS (required|optional): +system/file`, followed by a reason where there is one, then the counts of the +files the emulator cores add. From `python scripts/verify.py --platform retrodeck`: + +``` +RetroDECK: 2007/2008 OK, 1 untested [md5] + UNTESTED (required): nintendo-ds/dsifirmware.bin: declared hash contradicted by the local file + DISCREPANCY: nintendo-snes/sgb2.boot.rom: RetroDECK says OK but supersnes9x says size mismatch: got 524288, accepted [256] + supersnes9x [size] + Core files: 1495 in pack, 0 required missing, 1 optional missing +``` + +`--verbose` also lists the passing entries that an emulator validates, with +the check and the source lines behind it: + +``` +Recalbox: 351/351 OK [md5] + OK (required): macintosh/MacII.ROM + minivmac validates size=262144 [minivmac/cfg/CNFGRAPI.h:32, minivmac/src/OSGLUERETRO.c:781] +``` + +### Cross-reference section + +After per-file results, `verify.py` prints a cross-reference report. This lists +files that emulator cores need but that the platform YAML doesn't declare. These +files are still included in packs automatically, but the report helps identify +gaps in platform coverage data. + +The cross-reference uses `resolve_platform_cores()` to determine which emulator +profiles are relevant for each platform, then checks whether each profile's files +appear in the platform config. + +### Filtering output + +```bash +# By platform +python scripts/verify.py --platform batocera + +# By emulator core +python scripts/verify.py --emulator beetle_psx + +# By system +python scripts/verify.py --system sony-playstation + +# By hardware target +python scripts/verify.py --platform retroarch --target switch + +# JSON for scripted processing: the entries that need attention +python scripts/verify.py --platform retroarch --json +# every entry, passing ones included, with its ground truth +python scripts/verify.py --platform retroarch --json --verbose +``` diff --git a/wiki/reference-terms.md b/wiki/reference-terms.md index 9ed2edfe..7f892478 100644 --- a/wiki/reference-terms.md +++ b/wiki/reference-terms.md @@ -1,7 +1,6 @@ --- -level: reference -order: 130 -nav_title: Project terms +level: contribute +order: 90 --- # Terms of the reference pages @@ -149,8 +148,8 @@ everyday words (BIOS, emulator, core, hash) are in the [glossary](glossary.md). takes a file from (the collection, a data folder, a release asset) is its source, not its provenance. -:::tip[Next] +## Next steps + - [How each platform checks files](verification-modes.md) -- [Glossary](glossary.md) -::: +- [Words used on this site](glossary.md) diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 353e260c..6fccb7db 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -1,7 +1,6 @@ --- level: contribute order: 40 -nav_title: Testing --- # Testing guide @@ -56,7 +55,7 @@ Every module in `tests/`, by the area it guards. | Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | -| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_p0`, `test_site_r2_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture @@ -398,11 +397,12 @@ nav_title: Save files # only when the menu names it otherwise than its headin two without renumbering. The generated pages (systems, emulators, the gaps and data pages) hold fixed places among them, set in `generate_nav`. - `nav_title` is the menu label. Without it the menu shows the page's `# ` - heading. It is not `title`, which both MkDocs and Astro Starlight read as - the page's own title. + heading, which is the rule: a page keeps one name in the menu, its heading + and the links to it. It is not `title`, which both MkDocs and Astro + Starlight read as the page's own title. +- `group` places a Reference page under a group of that tab, such as + `Checking files` or `Data`. - `nav: false` keeps a page out of the menu on purpose. - `getting-started.md` does, since it only links the pages that took its - content. `python scripts/generate_site.py` stops with the page's name when `level` or `order` is missing, or when two pages of a tab take the same `order`. diff --git a/wiki/tools.md b/wiki/tools.md index c84eabb8..aabf6b41 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -1,6 +1,6 @@ --- -level: reference -order: 110 +level: contribute +order: 70 --- # Command-line tools diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index 6d7542d3..0c7df1ab 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -1,17 +1,18 @@ --- level: reference order: 10 +group: Checking files --- # How each platform checks files -Before a game starts, your setup may check its BIOS files, and each setup checks -differently. RetroArch only asks whether a file with the right name is in the -folder. Batocera computes the file's MD5 fingerprint and compares it with its -list, so a renamed or wrong-region file shows as `UNTESTED`: present, but not -the expected file. BizHawk does the same with SHA1. The way a setup checks -decides what "OK" means there: a file RetroArch accepts can still be one -Batocera reports. +Most setups can show which BIOS files are missing. Each one checks differently. +RetroArch only asks whether a file with the right name is in the folder. +Batocera computes the file's MD5 and compares it with its list, so a +wrong-region file, or another file given the right name, shows as `UNTESTED`: +present, but not the expected file. BizHawk does the same with SHA1. A file +RetroArch accepts can still be one Batocera reports. The emulator, not the +check, is what fails when a game starts. Example: `scph5501.bin`, MD5 `490f666e1afb15b7362b406ed1cea246`. Any file named `scph5501.bin` passes on RetroArch. On Batocera only those exact bytes pass. @@ -24,11 +25,14 @@ changes ([severity matrix](#severity-matrix)). +Each reference to a checker's code below links the revision of that code the +platform's own list was read from. + ## Existence mode **Platforms**: RetroArch, Lakka, RetroPie, MinUI, NextUI, MyMinUI -**Source**: RetroArch `core_info.c`, function `path_is_valid()`; minarch for the MinUI family +**Source**: RetroArch v1.22.2 [`core_info.c:2285`](https://github.com/libretro/RetroArch/blob/v1.22.2/core_info.c#L2285), `path_is_valid()`. For the MinUI family, minarch. A file is OK if it exists at the expected path. No hash is checked. Any file with the correct name passes, regardless of content. @@ -47,7 +51,7 @@ Lakka and RetroPie inherit this behavior through platform config inheritance MinUI, NextUI and MyMinUI check nothing at all. They hand each core its own folder, `Bios//`, and open nothing in it: minarch answers -`RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY` with that folder (MinUI `workspace/all/minarch/minarch.c:1889-1893` and `:2943`). The +`RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY` with that folder (MinUI [`workspace/all/minarch/minarch.c:1889-1893`](https://github.com/shauninman/MinUI/blob/dbf89435a5179788418ff3573042cf9c7e75d9e0/workspace/all/minarch/minarch.c#L1889-L1893) and [`:2943`](https://github.com/shauninman/MinUI/blob/dbf89435a5179788418ff3573042cf9c7e75d9e0/workspace/all/minarch/minarch.c#L2943)). The core decides what it loads, so the files are the ones its profile documents, placed below each folder a pak hands it (`core_system_dirs` in the platform YAML). @@ -62,6 +66,9 @@ The details vary by platform. ### Standard MD5 (Batocera, RetroBat) +**Source**: Batocera 43.1 `batocera-systems`, [`md5sum()`](https://github.com/batocera-linux/batocera.linux/blob/batocera-43.1/package/batocera/core/batocera-scripts/scripts/batocera-systems#L987-L992) and +function [`checkBios`](https://github.com/batocera-linux/batocera.linux/blob/batocera-43.1/package/batocera/core/batocera-scripts/scripts/batocera-systems#L1047). + `verify.py` replicates Batocera's `md5sum()` function. The file is read in binary mode, hashed with MD5, and compared case-insensitively against the expected value. @@ -77,7 +84,7 @@ and returns OK directly. ### MD5 shorter than 32 characters (Batocera) -Batocera's script compares the full string (`batocera-systems:1088`), so an MD5 +Batocera's script compares the full string ([`batocera-systems:1088`](https://github.com/batocera-linux/batocera.linux/blob/batocera-43.1/package/batocera/core/batocera-scripts/scripts/batocera-systems#L1088)), so an MD5 shorter than 32 characters matches no file there. The Batocera scraper completes such a value when exactly one MD5 in `database.json` starts with it (`_resolve_truncated_md5` in `scripts/scraper/batocera_scraper.py`) and keeps it as @@ -86,11 +93,12 @@ characters by prefix, and only for a file whose name the entry also names. ### md5_composite (Recalbox ZIP verification) -Recalbox hashes a ZIP with `Zip::Md5Composite` (`es-core/src/utils/Zip.cpp:71-96` in -recalbox-emulationstation at `f4cd0ae4`): one MD5 over the contents of every member, -read in filename order. The members' own MD5s are not involved, and neither is the -MD5 of the ZIP file itself: `Bios::Scan` takes the composite for every `.zip` -(`es-app/src/bios/Bios.cpp:64-68`). `verify.py` computes the same value with +Recalbox hashes a ZIP with `Zip::Md5Composite` +([`es-core/src/utils/Zip.cpp:71-96`](https://gitlab.com/recalbox/recalbox/-/blob/10.1.1/projects/frontend/es-core/src/utils/Zip.cpp#L71-96) in +Recalbox 10.1.1): one MD5 over the contents of every member, read in filename +order. The members' own MD5s are not involved, and neither is the MD5 of the ZIP +file itself: `Bios::Scan` takes the composite for every `.zip` +([`es-app/src/bios/Bios.cpp:86-91`](https://gitlab.com/recalbox/recalbox/-/blob/10.1.1/projects/frontend/es-app/src/bios/Bios.cpp#L86-91)). `verify.py` computes the same value with `md5_composite()` (`scripts/hashing.py`). The registry gives Recalbox `zip_digest: composite`. For a platform so marked, a @@ -109,8 +117,9 @@ listed hash is OK. Recalbox colours each entry of `es_bios.xml` from two attributes, `mandatory` and `hashMatchMandatory`, both true unless written `"false"` -(`es-app/src/bios/Bios.cpp:162-163` in recalbox-emulationstation at `f4cd0ae4`, -colours set by `Bios::Scan` at lines 84-108). The platform YAML carries them as +([`es-app/src/bios/Bios.cpp:187-188`](https://gitlab.com/recalbox/recalbox/-/blob/10.1.1/projects/frontend/es-app/src/bios/Bios.cpp#L187-188) in +Recalbox 10.1.1, colours set by `Bios::Scan` at +[lines 106-130](https://gitlab.com/recalbox/recalbox/-/blob/10.1.1/projects/frontend/es-app/src/bios/Bios.cpp#L106-130)). The platform YAML carries them as `required` and, when the attribute is written, `hash_match_mandatory`. | File | mandatory | hashMatchMandatory | Recalbox | verify.py | @@ -130,7 +139,7 @@ Recalbox shows RED for a mandatory entry with `hashMatchMandatory`. When a platform entry has a `zipped_file` field, the expected MD5 is not the hash of the ZIP container but of a specific ROM file inside the ZIP. `verify.py` replicates Batocera's -`checkInsideZip()`: +[`checkInsideZip()`](https://github.com/batocera-linux/batocera.linux/blob/batocera-43.1/package/batocera/core/batocera-scripts/scripts/batocera-systems#L994): 1. Open the ZIP. 2. Find the inner file by name (case-insensitive via `casefold()`). @@ -141,7 +150,7 @@ If the inner file is not found inside the ZIP, the status is UNTESTED with a rea ### RomM verification -**Source**: `backend/models/firmware.py`, `Firmware.verify_file_hashes` +**Source**: RomM 5.4.0 [`backend/models/firmware.py:58-76`](https://github.com/rommapp/romm/blob/5.4.0/backend/models/firmware.py#L58-L76), `Firmware.verify_file_hashes` RomM reads the file's bytes (`verification_mode: md5`) but does not compare one digest. Its platform YAML carries `hash_rule: size_and_any_hash`: the file's @@ -157,7 +166,7 @@ not a single digest gets a new `hash_rule` value, never a new mode. ### ROCKNIX verification -**Source**: `rocknix-systems`, function `checkBios` +**Source**: ROCKNIX 20261001 [`rocknix-systems:137-197`](https://github.com/ROCKNIX/distribution/blob/20261001/projects/ROCKNIX/packages/rocknix/sources/scripts/rocknix-systems#L137-L197), function `checkBios` ROCKNIX derives its BIOS checker from Batocera's, so the logic has the same shape: `md5sum()` on the file, `checkInsideZip()` for archive entries, and an @@ -167,8 +176,9 @@ platform declares is mandatory in its own data, so the platform YAML marks all ### KNULLI, AmberELEC and EmuELEC verification -**Source**: `knulli-systems` (KNULLI) and `batocera-systems` (AmberELEC, -EmuELEC), function `checkBios` +**Source**: function `checkBios` in [`knulli-systems:867-928`](https://github.com/knulli-cfw/knulli-linux/blob/20260511/package/system/knulli-scripts/scripts/knulli-systems#L867-L928) +(KNULLI 20260511), and in `batocera-systems` for +[AmberELEC](https://github.com/AmberELEC/AmberELEC/blob/a21975bfb1a3af68f66e07a931b994b523e1e6e2/packages/amberelec/sources/scripts/batocera-systems#L583-L644) and [EmuELEC 4.8](https://github.com/EmuELEC/EmuELEC/blob/v4.8/packages/sx05re/emuelec/bin/batocera/batocera-systems#L817-L834) All three keep Batocera's `systems` dict and its checker, run by their EmulationStation's BIOS screen. @@ -205,6 +215,8 @@ URL and install themselves, so they are not part of the pack. **Platforms**: BizHawk +**Source**: BizHawk 2.11.1 [`FirmwareDatabase.cs`](https://github.com/TASEmulators/BizHawk/blob/2.11.1/src/BizHawk.Emulation.Common/Database/FirmwareDatabase.cs) + BizHawk firmware entries use SHA1 as the primary hash. `verify.py` computes SHA1 via `compute_hashes()` and compares case-insensitively. @@ -437,152 +449,8 @@ dump with that CRC32 too (`bios/Dragon/Dragon/d64_1.rom`), but its MD5 is would not accept it at that path (step 3). The RetroBat file ships, and the [gaps page](../gaps.md) lists the discrepancy with the dump XRoar accepts. -## Reading this project's own reports +## Next steps -The sections below are about `verify.py` run on a clone of the repository. -Your own BIOS folder is checked with the installer's `--check` -([When a game does not start](troubleshooting.md)). - -## Hash mismatch / UNTESTED - -This section and the two after it are about this repository's own tools, run -from a clone. `scripts/verify.py` checks the collection in `bios/` against each -platform, never your BIOS folder: for that, use the installer's `--check` -([When a game does not start](troubleshooting.md)). - -`verify.py` reports `UNTESTED` for a file of the collection that was hashed -and does not match what the platform declares. The line gives the reason: - -``` - UNTESTED (required): nintendo-ds/dsifirmware.bin: declared hash contradicted by the local file -``` - -The system page on the site lists every known version of the file with its -expected MD5 and SHA1. - -### Existence-mode platforms never report UNTESTED - -On RetroArch, Lakka and RetroPie, the platform check only asks whether the file -is there, so the only outcomes are OK and MISSING. `verify.py` adds the emulator -ground truth from the profiles and flags a `DISCREPANCY` when a file passes the -platform check but fails the emulator's own size or hash validation. - -### The .variants/ directory - -When multiple versions of the same BIOS exist (different revisions, regions, or -dumps), the primary version lives in the main directory and alternatives live in -`.variants/`. `verify.py` checks the primary file first, then falls back to -variants when resolving by hash. - -A file that matches a variant hash but not the primary works. It is not the -version the emulator tries first, and some cores accept several versions. - -## Pack is missing files - -A generated pack doesn't contain all the files you expected. - -### Severity levels - -`verify.py` assigns a severity to each issue, counted under `severity_counts` -in its `--json` output. Not all missing files are equally important: - -| Severity | Meaning | Action needed | -|----------|---------|---------------| -| CRITICAL | Required file missing on an MD5 or SHA1 platform | Must fix. Core won't function. | -| WARNING | Optional file missing on an MD5 or SHA1 platform, hash mismatch (UNTESTED) on any hash platform, or a required file missing on an existence platform | Core works but with reduced functionality, or runs on content nobody verified. | -| INFO | Optional file missing on an existence-only platform, or a missing file the core covers with an HLE fallback | Core works fine, BIOS improves accuracy. | -| OK | File present and verified | No action needed. | - -The full mapping, including how `hle_fallback` overrides the platform mode, is -in [severity matrix](#severity-matrix). - -Focus on CRITICAL issues first. WARNING files improve the experience but aren't -strictly necessary. INFO files are nice to have. - -### Large files (release assets) - -Files like PS3UPDAT.PUP, PSVUPDAT.PUP, and Switch firmware are too large for the -git repository. They are stored as GitHub release assets under the `large-files` -release and downloaded at build time. - -If a pack build fails to include these, check your network connection. In offline -mode (`--offline`), large files are only included if already cached locally in -`.cache/large/`. - -### Data directories - -Some cores need entire directory trees rather than individual files (e.g. Dolphin's -`Sys/` directory, PPSSPP's `assets/`). These are fetched by `refresh_data_dirs.py` -from upstream repositories. - -In offline mode, data directories are only included if already cached in `data/`. -Run `python scripts/refresh_data_dirs.py` to fetch them. - -## verify.py reports errors - -How to read and interpret `verify.py` output. - -### Status codes - -| Status | Meaning | -|--------|---------| -| `OK` | File present, hash matches (or existence check passed) | -| `UNTESTED` | File present, hash not confirmed against the expected value | -| `MISSING` | File not found in the repository | -| `DISCREPANCY` | The platform check passes, an emulator's own check fails | - -### Reading the output - -The first line counts the platform's files and names its check. Under it come -the entries that need attention, one per line as `STATUS (required|optional): -system/file`, followed by a reason where there is one, then the counts of the -files the emulator cores add. From `python scripts/verify.py --platform retrodeck`: - -``` -RetroDECK: 2007/2008 OK, 1 untested [md5] - UNTESTED (required): nintendo-ds/dsifirmware.bin: declared hash contradicted by the local file - DISCREPANCY: nintendo-snes/sgb2.boot.rom: RetroDECK says OK but supersnes9x says size mismatch: got 524288, accepted [256] - supersnes9x [size] - Core files: 1495 in pack, 0 required missing, 1 optional missing -``` - -`--verbose` also lists the passing entries that an emulator validates, with -the check and the source lines behind it: - -``` -Recalbox: 351/351 OK [md5] - OK (required): macintosh/MacII.ROM - minivmac validates size=262144 [minivmac/cfg/CNFGRAPI.h:32, minivmac/src/OSGLUERETRO.c:781] -``` - -### Cross-reference section - -After per-file results, `verify.py` prints a cross-reference report. This lists -files that emulator cores need but that the platform YAML doesn't declare. These -files are still included in packs automatically, but the report helps identify -gaps in platform coverage data. - -The cross-reference uses `resolve_platform_cores()` to determine which emulator -profiles are relevant for each platform, then checks whether each profile's files -appear in the platform config. - -### Filtering output - -```bash -# By platform -python scripts/verify.py --platform batocera - -# By emulator core -python scripts/verify.py --emulator beetle_psx - -# By system -python scripts/verify.py --system sony-playstation - -# By hardware target -python scripts/verify.py --platform retroarch --target switch - -# JSON for scripted processing: the entries that need attention -python scripts/verify.py --platform retroarch --json -# every entry, passing ones included, with its ground truth -python scripts/verify.py --platform retroarch --json --verbose -``` +- [Reading this project's own reports](reading-reports.md): `verify.py` output, + status codes and severity levels +- [What the packs do not hold](../gaps.md)