diff --git a/install.ps1 b/install.ps1 index 17921a9d..ae967ff4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "300fcfd077c970a6807b2414b19cbae87b330664f3f7092d8275f5da2fe6e022" +$defaultInstallSha256 = "6245355aa8a65872b761229f2429ca6e0953573f0b75f6f30286a2f067c83afd" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index af1a72dd..df299e90 100644 --- a/install.py +++ b/install.py @@ -3505,6 +3505,49 @@ def _older_list_advice() -> str: "installer again to fetch the current one.") +def _narrow_to_emulators( + names: list[str], manifest: dict, plat: str, + files: list[dict], omitted: list[dict], +) -> tuple[list[dict], list[dict]]: + """Keep what the named emulators read, as the pack built for them. + + An emulator page offers this as one command, so a name the platform + does not run is refused rather than read as nothing to install. + """ + if "system_cores" not in manifest: + print(f"Error: the {plat} file list does not say which emulators read " + "which systems, which --emulator needs.", file=sys.stderr) + print(_older_list_advice(), file=sys.stderr) + sys.exit(1) + known = sorted({ + name + for profile, aliases in (manifest.get("core_names") or {}).items() + for name in [profile, *aliases] + } | set(available_choices(files, omitted)["cores"])) + try: + chosen = resolve_choices(names, known, "emulator") + except ValueError as exc: + print(f"Error: {plat}: {exc}", file=sys.stderr) + sys.exit(1) + before = len(files) + files, omitted = narrow_to_cores(manifest, chosen, files, omitted) + # A whole-platform install keeps the platform's files of a system no + # core is mapped to. Here only the named emulators' systems are wanted. + cores = designated_cores(manifest, chosen) + reached = { + system for system, readers in (manifest.get("system_cores") or {}).items() + if cores.intersection(readers) + } + + def theirs(entry: dict) -> bool: + return entry.get("cores") is not None or bool(reached.intersection(entry_systems(entry))) + + files = [entry for entry in files if theirs(entry)] + omitted = [entry for entry in omitted if theirs(entry)] + print(f" Narrowed {before} -> {len(files)} files to {', '.join(chosen)}") + return files, omitted + + def _requested_narrowing( args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict] ) -> tuple[list[str], list[str], list[str]] | None: @@ -3588,6 +3631,13 @@ def main() -> None: action="store_true", help="opt in to copies into detected standalone-emulator directories", ) + parser.add_argument( + "--emulator", + action="append", + metavar="NAME", + help="install what these emulators read on the platform, as a pack " + "built for them carries (names: --list-cores)", + ) parser.add_argument( "--installed-cores", action="store_true", @@ -3946,6 +3996,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) print(f" Narrowed {before} -> {len(files)} files to the installed cores") + if args.emulator: + files, omitted_files = _narrow_to_emulators( + args.emulator, manifest, plat_name, files, omitted_files + ) + selection = _requested_narrowing(args, plat_name, files, omitted_files) if ( selection is None diff --git a/install.sh b/install.sh index d47910d6..aa69bfa5 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="300fcfd077c970a6807b2414b19cbae87b330664f3f7092d8275f5da2fe6e022" +DEFAULT_INSTALL_SHA256="6245355aa8a65872b761229f2429ca6e0953573f0b75f6f30286a2f067c83afd" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 19e7ff48..574c9110 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -2969,6 +2969,37 @@ def _render_metadata_rows(profile: dict) -> list[str]: return lines +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. + """ + if profile.get("type") in ("alias", "launcher", "test"): + return [] + base = "https://raw.githubusercontent.com/Abdess/retrobios/main" + return [ + '??? 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.", + "", + " Linux, macOS, Steam Deck:", + "", + " ```sh", + f" curl -fsSL {base}/install.sh | sh -s -- --emulator {name}", + " ```", + "", + " Windows (PowerShell):", + "", + " ```powershell", + f" &([scriptblock]::Create((irm {base}/install.ps1))) --emulator {name}", + " ```", + "", + ] + + def _render_platform_details(profile: dict) -> list[str]: """The per-platform block, one level of nesting deep.""" lines: list[str] = [] @@ -3287,6 +3318,8 @@ def generate_emulator_page( lines.append("") lines.append("") + lines.extend(_install_block(name, profile)) + lines.extend(_render_platform_details(profile)) lines.extend(_render_structured_blocks(profile)) diff --git a/tests/test_install.py b/tests/test_install.py index 2e722d69..aad14c79 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2902,6 +2902,39 @@ class ARefusedStandaloneCopyIsNamed(unittest.TestCase): +class OneEmulatorsFilesInOneCommand(unittest.TestCase): + """An emulator page offers one command for that emulator's files. + --core kept the platform's whole list, and narrowing to cores kept the + platform files of every system no core is mapped to.""" + + MANIFEST = { + "system_cores": {"ibm-pc": ["pcem"], "nintendo-nes": ["fceumm"]}, + "core_names": {"pcem": ["pcem"], "fceumm": ["fceumm"]}, + "files": [], + } + + def test_only_its_files_and_its_systems(self): + import contextlib # noqa: PLC0415 + + files = [ + {"dest": "pcem/ibmpc.bin", "cores": ["pcem"], "systems": ["ibm-pc"]}, + {"dest": "disksys.rom", "cores": None, "systems": ["nintendo-nes"]}, + {"dest": "ep128emu/exos.rom", "cores": None, "systems": ["enterprise"]}, + {"dest": "ibm.rom", "cores": None, "systems": ["ibm-pc"]}, + ] + with contextlib.redirect_stdout(io.StringIO()): + kept, _omitted = install._narrow_to_emulators( + ["PCem"], self.MANIFEST, "retroarch", files, []) + self.assertEqual(sorted(e["dest"] for e in kept), ["ibm.rom", "pcem/ibmpc.bin"]) + + def test_an_unknown_name_is_refused(self): + import contextlib # noqa: PLC0415 + + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + install._narrow_to_emulators(["nope"], self.MANIFEST, "retroarch", [], []) + + + class ThePagesQuoteTheBootstrapsOwnWords(unittest.TestCase): """The installer page and the FAQ quoted "install.py SHA-256 mismatch", a message the bootstraps no longer print.""" diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index 90f4bd0d..dd5e4bab 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -818,5 +818,22 @@ class ACardSaysWhyAFileIsLeftOut(unittest.TestCase): self.assertIn("installed with the standalone emulator", badges) +class AnEmulatorPageOffersItsInstall(unittest.TestCase): + """An emulator page linked each file one by one and offered no way to + install them together.""" + + def test_the_command_names_the_emulator(self): + from generate_site import _install_block + + text = "\n".join(_install_block("pcem", {"type": "libretro"})) + self.assertIn("install.sh | sh -s -- --emulator pcem", text) + self.assertIn("install.ps1))) --emulator pcem", text) + self.assertEqual(_install_block("x", {"type": "alias"}), []) + + def test_the_installer_takes_the_option(self): + source = (ROOT / "install.py").read_text(encoding="utf-8") + self.assertIn('"--emulator"', source) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index b9b9b456..d3263ce6 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -219,6 +219,7 @@ what the options name, everything by default. | `--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) | | `--system NAMES` | Install only these systems. Comma-separated or repeated. `--list-systems` prints the names, `other` included | | `--core NAMES` | Keep the platform's own list and only the extra files these cores load. `--list-cores` prints the names | +| `--emulator NAMES` | Install what these emulators read on the detected platform, and nothing else: their own files and the platform's files of the systems they run. Each emulator page on the site gives this command. `--list-cores` prints the names | | `--region LIST` | Preferred regions, best first (`us,eu,jp`): keep one BIOS per region where a system has several. Accepts the names and aliases of `generate_pack.py --region` | | `--list-systems` | Print a platform's systems with the files and size each holds | | `--list-cores` | Print the cores whose extra files a platform installs |