From 7d412939fa9220b3fd98ff681b95c8d7a3dc6f9a Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 19:23:01 +0200
Subject: [PATCH] feat: system pages by arcade board and start file
Start lines count which emulators require a file, arcade cards are grouped by board, romsets match by CRC32, cards name emulators and group setups by place, and parts carry parent, neighbours and a name.
---
scripts/generate_site.py | 831 +++++++++++++++++++++++++++++++--------
1 file changed, 671 insertions(+), 160 deletions(-)
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 446e91a3..cd7e3ec4 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -4113,7 +4113,9 @@ def generate_systems_index(manufacturers: dict) -> str:
"as the data of a game, rather than the BIOS of a machine."),
):
rows = sorted(
- ((_console_display(mfr, console), mfr, console, len(files))
+ ((_folder_emulator(console)[0] if group == "programs"
+ else _section_label(mfr, console) if mfr == "Arcade"
+ else _console_display(mfr, console), mfr, console, len(files))
for mfr, consoles in manufacturers.items() if kind(mfr) == group
for console, files in consoles.items()),
key=lambda row: (row[0].casefold(), row[1].casefold(), row[2]),
@@ -4123,6 +4125,19 @@ def generate_systems_index(manufacturers: dict) -> str:
lines += [f"## {heading}", ""]
if note:
lines += [note, ""]
+ if group == "programs":
+ # Each folder by the emulator's name, linked to its page: the
+ # rows read a7800 and azaharplus beside console names, and
+ # every maker cell said Other.
+ lines += ["| Emulator | Files |", "|---------|------:|"]
+ for name, mfr, console, count in rows:
+ href = console_href(_slug(mfr), console).removeprefix("systems/")
+ key = _folder_emulator(console)[1]
+ cell = name.replace("|", "-")
+ cell = f"[{cell}](../emulators/{key}.md)" if key else cell
+ lines.append(f"| {cell} | [{_count(count, 'file')}]({href}) |")
+ lines.append("")
+ continue
lines += [
# The count before the maker: on a phone the last column is
# the one scrolled off, and most names carry the maker already.
@@ -4138,6 +4153,23 @@ def generate_systems_index(manufacturers: dict) -> str:
return "\n".join(lines) + "\n"
+def _folder_emulator(folder: str) -> tuple[str, str]:
+ """The display name and profile of the emulator a folder under Other is
+ named after, by its profile key or its name, else the folder and "".
+ """
+ profiles = _profiles_on_disk()
+ if folder in profiles and profiles[folder].get("type") not in ("alias", "test"):
+ return str(profiles[folder].get("emulator") or folder), folder
+ for key in sorted(profiles):
+ profile = profiles[key]
+ if profile.get("type") in ("alias", "test"):
+ continue
+ names = {str(profile.get(field) or "").casefold() for field in ("emulator", "display_name")}
+ if folder.casefold() in names:
+ return str(profile.get("emulator") or key), key
+ return folder, ""
+
+
_PROVENANCE_LABELS = {"redump": "Redump", "no-intro": "No-Intro", "tosec": "TOSEC"}
_PROVENANCE_HOMES = {
@@ -4508,13 +4540,20 @@ def _emulator_links(sha1: str, consumers: FileConsumers) -> list[str]:
entries = getattr(consumers, "emulator_entries", {}).get(sha1, {})
files_of = getattr(consumers, "emulator_files", {})
links = []
- for emulator in sorted(consumers.emulators.get(sha1, ())):
+ # By the name the emulator's page carries: 19,499 card links read
+ # melonds_ds where its page says melonDS DS.
+ named = sorted(((_emulator_display(e), e) for e in consumers.emulators.get(sha1, ())),
+ key=lambda pair: (pair[0].casefold(), pair[1]))
+ for display, emulator in named:
index = entries.get(emulator)
anchor = (_emulator_card_anchor(emulator, files_of[emulator], index)
if index is not None and emulator in files_of else "")
page = emulator_card_page(emulator, anchor) if anchor else emulator
fragment = f"#{anchor}" if anchor else ""
- links.append(f"[{emulator}](../emulators/{page}.md{fragment})")
+ # The card's facts are raw HTML: "Game & Watch" is escaped, and a
+ # bracket would end the link the facts are parsed from.
+ text = html.escape(display, quote=False).replace("[", "[").replace("]", "]")
+ links.append(f"[{text}](../emulators/{page}.md{fragment})")
return links
@@ -4529,17 +4568,23 @@ def _installed_places(sha1: str, consumers: FileConsumers, declared: set[str]) -
return sorted(places, key=lambda place: place[0].casefold())
-def _places_text(places: list[Place]) -> str:
- """Setups as "Lakka: scph1000.bin", the name linked to its page and the
- destination as text: a code chip per path made the line a row of
- boxes."""
+def _places_lines(places: list[Place]) -> list[str]:
+ """Setups grouped by where they put the file, one line per place:
+ "`scph5501.bin`: Batocera, EmuDeck". Each setup repeating the name
+ took five lines where one says it. A setup that names no place (EmuDeck
+ matches by MD5) is listed on a line of its own, last."""
def plain(text: str) -> str:
return html.escape(text, quote=False).replace("`", "`")
- return ", ".join(
- f"[{name}]({href})" + (": " + " and ".join(plain(d) for d in dests) if dests else "")
- for name, href, dests in places
- )
+ by_place: dict[tuple[str, ...], list[str]] = {}
+ for name, href, dests in places:
+ by_place.setdefault(tuple(dests), []).append(f"[{name}]({href})")
+ ordered = sorted(by_place.items(), key=lambda item: (not item[0], -len(item[1]), item[0]))
+ return [
+ (" and ".join(f"{plain(d)}" for d in dests) + ": " if dests else "")
+ + ", ".join(links)
+ for dests, links in ordered
+ ]
def _inline_card_html(text: str) -> str:
@@ -4640,10 +4685,12 @@ def _source_order(source: str) -> tuple[bool, str]:
def _romset_line(f: dict) -> str:
"""Which MAME and FBNeo releases a collected archive's contents match.
- MAME and FBNeo check the members of an archive, by CRC32, never its
- own bytes: the set works with a release whose recipe lists the same
- members. Empty when no recipe names the archive or the file is not
- on disk.
+ MAME and FBNeo check the members of an archive by CRC32, never its own
+ bytes or its member names: a set works with a release when it holds
+ every CRC32 the release's recipe lists, extra members or not. The
+ main neogeo.zip, which every MAME from 0.190 to 0.289 loads, read "No
+ match" when names had to agree and no member could be extra. Empty
+ when no recipe names the archive or the file is not on disk.
"""
recipes = _romset_releases().get(str(f.get("name", "")))
path = _REPO_ROOT / str(f.get("path", ""))
@@ -4656,14 +4703,42 @@ def _romset_line(f: dict) -> str:
except (OSError, zipfile.BadZipFile) as exc:
print(f" WARNING: {path}: {exc}", file=sys.stderr)
return ""
- matched: dict[str, list[Release]] = {}
+ crcs = {crc for _name, crc in held}
+ same: dict[str, list[Release]] = {}
+ holds: dict[str, list[Release]] = {}
+ # source -> (fewest CRC32s missing, the latest release missing that many)
+ closest: dict[str, tuple[int, Release]] = {}
for members, source, agreed in recipes:
+ missing = len({crc for _name, crc in members} - crcs)
if members == held:
- matched.setdefault(source, []).extend(agreed)
- if not matched:
+ same.setdefault(source, []).extend(agreed)
+ elif not missing:
+ holds.setdefault(source, []).extend(agreed)
+ elif agreed:
+ latest = max(agreed)
+ best = closest.get(source)
+ if best is None or (missing, -latest[0]) < (best[0], -best[1][0]):
+ closest[source] = (missing, latest)
+
+ def runs(found: dict[str, list[Release]], skip: dict[str, list[Release]]) -> str:
+ return " and ".join(
+ f"{_ROMSET_SOURCES.get(source, source)} {_release_runs(kept)}"
+ for source in sorted(found, key=_source_order)
+ for kept in [[r for r in found[source] if r not in skip.get(source, [])]] if kept)
+
+ said = []
+ if same:
+ said.append(f"Same contents as {runs(same, {})}")
+ if holds and runs(holds, same):
+ said.append(f"Holds every ROM of {runs(holds, same)}")
+ if said:
+ return ". ".join(said)
+ if not closest:
return f"No match in {_romset_coverage()}"
- return ", ".join(f"{_ROMSET_SOURCES.get(source, source)} {_release_runs(matched[source])}"
- for source in sorted(matched, key=_source_order))
+ return "No match: missing " + ", ".join(
+ f"{_count(missing, 'ROM')} of {_ROMSET_SOURCES.get(source, source)} {release[1]}"
+ for source, (missing, release) in sorted(closest.items(),
+ key=lambda item: _source_order(item[0])))
@functools.lru_cache(maxsize=1)
@@ -4736,17 +4811,16 @@ def _system_file_card(
facts: list[tuple[str, list[str], bool]] = []
if plats:
# Each link lands on the platform's system section, and says where
- # that platform puts the file. The site search reads this line: it
- # names the file as each setup names it, and with the name only in
- # its title the card ranked below every guide citing scph5501 as an
- # example.
+ # that platform puts the file. Out of the search: the card's title
+ # weighs the most a word can, and the list made the excerpt of
+ # every card a run of setup names and paths.
facts.append(("On the setup's list",
- [_places_text(_platform_places(sha1_full, consumers))], True))
+ _places_lines(_platform_places(sha1_full, consumers)), False))
if installed:
# The packs that carry the file for their cores: a RetroBat user
# looking up scph5501.bin found every platform but RetroBat, whose
# pack installs it for six cores.
- facts.append(("Packed for its emulators by", [_places_text(installed)], False))
+ facts.append(("Packed for its emulators by", _places_lines(installed), False))
if emus:
facts.append(("Emulators", [", ".join(_emulator_links(sha1_full, consumers))], False))
romset = _romset_line(f)
@@ -4947,11 +5021,12 @@ def system_install_choices(coverages: dict) -> dict[str, dict[str, list[tuple[st
SYSTEM_PAGE_BUDGET = 300_000
# What the sections kept on a manufacturer's summary may weigh once
# rendered, estimated from their Markdown. Fitted on the Nintendo, Sega and
-# Commodore summaries: highlighted install commands grow about nine times,
-# cards 1.7 times. The page budget is 600 KB of HTML, and the frame around
-# the content (head, navigation, footer) is about 45 KB.
+# Commodore summaries: highlighted install commands grow about eleven times
+# since each carries its setup's name, cards 1.7 times. The page budget is
+# 600 KB of HTML, and the frame around the content (head, navigation,
+# footer) is about 45 KB.
CARD_HTML_RATIO = 1.7
-INSTALL_HTML_RATIO = 9
+INSTALL_HTML_RATIO = 11
SYSTEM_SUMMARY_HTML = 500_000
# A system card: whether it is a variant, its lines, its sha1.
@@ -4972,22 +5047,25 @@ def _reading_entries(sha1: str, consumers: FileConsumers) -> list[dict]:
_NO_PRIORITY = 1 << 30
-def _card_rank(f: dict, consumers: FileConsumers) -> tuple:
- """Where a file's card sits in its console: the files an emulator
- requires first, then by the emulators' search order (smallest first),
- then a required file by how many emulators read it, then by how many
- setups list the file, then by name.
+def _card_rank(f: dict, consumers: FileConsumers, start: dict[str, int] | None = None) -> tuple:
+ """Where a file's card sits in its console: the files the section's
+ start line names first, in its order (``start``, sha1 -> place), then
+ the files an emulator requires, then by the emulators' search order
+ (smallest first), then a required file by how many emulators read it,
+ then by how many setups list the file, then by name.
By byte order the PlayStation section opened on LibPS.exe and
PSX_DTLH2000.bin, capitals first, and scph5501.bin was 28th. Without
the reader count the Neo Geo section opened on aes.zip, which geolith
- alone reads.
+ alone reads. Without the start line's order, "first below" named
+ neogeo.zip over a section opening on sfix.sfix.
"""
sha1 = str(f.get("sha1", ""))
entries = _reading_entries(sha1, consumers)
priorities = [e["priority"] for e in entries if isinstance(e.get("priority"), int)]
required = any(e.get("required", True) for e in entries)
return (
+ (start or {}).get(sha1, _NO_PRIORITY),
not required,
min(priorities, default=_NO_PRIORITY),
-len(entries) if required else 0,
@@ -5006,12 +5084,20 @@ def _console_cards(
consumers: FileConsumers,
anchored: set[str],
by_name: bool = False,
+ start: list[str] | None = None,
) -> list[Card]:
"""Each card of one console, primaries then variants, with whether it
- is a variant. ``by_name`` orders by name alone, for a console cut into
- parts named by their first and last file."""
+ is a variant. ``start`` is the sha1s the start line names, whose cards
+ come first in its order. ``by_name`` orders the rest by name alone,
+ for a console cut into parts named by their first and last file: the
+ PlayStation 2 opened on cheats_ni.zip, its BIOS far below."""
searchable = manufacturer not in GAME_DATA_TOPS
- order = _by_name if by_name else (lambda f: _card_rank(f, consumers))
+ place = {sha1: index for index, sha1 in enumerate(start or [])}
+
+ def order(f: dict) -> tuple:
+ if by_name:
+ return (place.get(str(f.get("sha1", "")), _NO_PRIORITY), *_by_name(f))
+ return _card_rank(f, consumers, place)
main_files = [f for f in files if "/.variants/" not in f["path"]]
variant_files = [f for f in files if "/.variants/" in f["path"]]
cards = [(False, _system_file_card(f, consumers, anchored, searchable), str(f.get("sha1", "")))
@@ -5149,29 +5235,55 @@ class _StartGroup:
# Files an emulator reads when present and replaces when absent.
self.optional: list[str] = []
self.emulators: list[str] = []
+ # Each file an emulator requires, by its visible name, with the
+ # emulators requiring it and the name as the line writes it.
+ self.requires: dict[str, set[str]] = {}
+ self.written: dict[str, str] = {}
+ # Visible name -> the sha1 of the file it names.
+ self.sha1s: dict[str, str] = {}
+ # Emulators that replace a missing file with their own code.
+ self.hle: set[str] = set()
+ # Files an emulator reads when present and runs the same without.
+ self.extra: list[str] = []
+
+ def require(self, name: str, emulator: str) -> None:
+ self.written.setdefault(_visible(name), name)
+ self.requires.setdefault(_visible(name), set()).add(emulator)
def absorb(self, other: _StartGroup) -> None:
for region, names in other.by_region.items():
for name in names:
_add_name(self.by_region.setdefault(region, []), name)
for mine, theirs in ((self.plain, other.plain), (self.optional, other.optional),
- (self.emulators, other.emulators)):
+ (self.extra, other.extra), (self.emulators, other.emulators)):
for name in theirs:
_add_name(mine, name)
+ for visible, emulators in other.requires.items():
+ self.written.setdefault(visible, other.written[visible])
+ self.requires.setdefault(visible, set()).update(emulators)
+ for visible, sha1 in other.sha1s.items():
+ self.sha1s.setdefault(visible, sha1)
+ self.hle.update(other.hle)
+
+ def requirers(self) -> set[str]:
+ return set().union(*self.requires.values()) if self.requires else set()
def _start_groups(
files: list[dict], consumers: FileConsumers, section: tuple[str, str] | None
) -> dict[str | None, _StartGroup]:
"""The files of a section an emulator reads, by the system they are
- read for. An entry whose system leads to another section is that
- section's: the DS section named five DSi files. A file the system
- requires but filed under Arcade, where sets serve every machine, is
- named too, linked to its card: the Neo Geo BIOS. One filed under
- another console is that console's, whatever system an emulator files
- it under (BizHawk reads the Mega CD BIOS for its Genesis), and one
- under Other is a single emulator's, its shaders and fonts. A file the
- emulator ships with is no file to fetch."""
+ read for. An entry is the section's when one of its systems leads
+ here: the DS section named five DSi files, and the NES one FBNeo's
+ YM2608 sample, read for a system with no section of its own. An entry
+ that names no system, in a profile of several, reads the section's own
+ console. A file the system requires but filed under
+ Arcade, where sets serve every machine, is named too, linked to its
+ card: the Neo Geo BIOS. One filed under another console is that
+ console's, whatever system an emulator files it under (BizHawk reads
+ the Mega CD BIOS for its Genesis), and one under Other is a single
+ emulator's, its shaders and fonts. A file the emulator ships with is
+ no file to fetch."""
from siterender import _heading_name, _manufacturer_slug, system_name, system_section # noqa: PLC0415
here = (_manufacturer_slug(section[0]), section[1]) if section else None
@@ -5184,15 +5296,15 @@ def _start_groups(
if entry.get("bundled"):
continue
system = None
- if here is not None:
- places = {s: system_section(s) for s in _entry_systems(emulator, entry)}
- system = next((s for s, place in places.items() if place == here), None)
- if system is None and any(places.values()):
+ systems = _entry_systems(emulator, entry)
+ if here is not None and systems:
+ system = next((s for s in systems if system_section(s) == here), None)
+ if system is None:
continue
kept.setdefault(sha1, []).append((emulator, entry, system))
candidates = list(primaries)
+ known = (getattr(consumers, "_db", None) or {}).get("files", {})
if here is not None:
- known = (getattr(consumers, "_db", None) or {}).get("files", {})
for sha1, emulator, entry, system in _entries_by_section(consumers).get(here, []):
record = known.get(sha1)
parts = str((record or {}).get("path", "")).split("/")
@@ -5204,25 +5316,56 @@ def _start_groups(
kept.setdefault(sha1, []).append((emulator, entry, system))
groups: dict[str | None, _StartGroup] = {}
- for f in sorted(candidates, key=lambda f: _card_rank(f, consumers)):
+ ranked = sorted(candidates, key=lambda f: _card_rank(f, consumers))
+ rendered: dict[str, str] = {}
+ for f in ranked:
sha1 = str(f.get("sha1", ""))
pairs = kept.get(sha1, [])
name = _code(_setup_name(f, consumers, [(e, entry) for e, entry, _s in pairs]))
if f.get("elsewhere"):
name = f"[{name}]({markup.site_href(f'files/?sha1={sha1}')})"
- for emulator, entry, system in pairs:
+ rendered[sha1] = name
+ # A member read inside its archive is the archive's to name: FinalBurn
+ # Neo reads sfix.sfix inside neogeo.zip, and the line had it read
+ # sfix.sfix instead of neogeo.zip.
+ archives = {_visible(name): (name, sha1) for sha1, name in reversed(rendered.items())}
+ by_name = (getattr(consumers, "_db", None) or {}).get("indexes", {}).get("by_name", {})
+
+ def archive_named(archive: str) -> tuple[str, str]:
+ """The archive as the line names it, linked to its card when the
+ collection holds one, with that card's sha1."""
+ if archive in archives:
+ return archives[archive]
+ found = by_name.get(archive) or []
+ found = [found] if isinstance(found, str) else sorted(found)
+ found.sort(key=lambda s: "/.variants/" in str(known.get(s, {}).get("path", "")))
+ if not found:
+ return _code(archive), ""
+ return f"[{_code(archive)}]({markup.site_href(f'files/?sha1={found[0]}')})", ""
+
+ for f in ranked:
+ sha1 = str(f.get("sha1", ""))
+ for emulator, entry, system in kept.get(sha1, []):
+ name, named = rendered[sha1], sha1
+ if entry.get("archive"):
+ name, named = archive_named(str(entry["archive"]))
try:
key = system_name(system) if system else None
except KeyError:
key = None
group = groups.setdefault(key, _StartGroup())
+ if named:
+ group.sha1s.setdefault(_visible(name), named)
if emulator not in group.emulators:
group.emulators.append(emulator)
if entry.get("hle_fallback"):
_add_name(group.optional, name)
+ group.hle.add(emulator)
continue
if not entry.get("required", True):
+ _add_name(group.extra, name)
continue
+ group.require(name, emulator)
regions = {str(r) for r in entry.get("region") or []}
for names in [group.by_region.setdefault(r, []) for r in regions] or [group.plain]:
_add_name(names, name)
@@ -5245,20 +5388,75 @@ def _start_with(
``section`` is the (manufacturer, console) the files are under: an
entry read for another console's system is left to that console.
Read from the profiles' system, required, hle_fallback and region
- fields, never typed per system. A system whose emulators all run
- without its files says so. A section where no emulator reads a file
- gets no line.
+ fields, never typed per system. A system most of whose emulators run
+ without its files says so, and names the few that require one. A
+ section where no emulator reads a file gets no line.
"""
+ return _start(files, consumers, first_below, section)[0]
+
+
+def _start_order(
+ files: list[dict], consumers: FileConsumers, section: tuple[str, str] | None = None,
+) -> list[str]:
+ """The sha1s of the files a section's start line names, in its order:
+ the cards come in that order, so "first below" is true."""
+ return _start(files, consumers, True, section)[1]
+
+
+def _start(
+ files: list[dict], consumers: FileConsumers, first_below: bool,
+ section: tuple[str, str] | None,
+) -> tuple[list[str], list[str]]:
+ """The start line and the sha1s it names, in order."""
groups = _start_groups(files, consumers, section)
- blocks = {key: _group_start(group, first_below) for key, group in groups.items()}
- blocks = {key: block for key, block in blocks.items() if block}
+ sha1s: dict[str, str] = {}
+ for group in groups.values():
+ for visible, sha1 in group.sha1s.items():
+ sha1s.setdefault(visible, sha1)
+ held = {str(f.get("sha1", "")) for f in files}
+ lines = _lead_file(groups)
+ if not lines:
+ lines = _group_lines(groups, first_below, held, sha1s)
+ order: list[str] = []
+ for line in lines:
+ for visible in re.findall(r"`([^`]*)`", line):
+ sha1 = sha1s.get(visible)
+ if sha1 and sha1 not in order:
+ order.append(sha1)
+ return lines, order
+
+
+def _group_lines(
+ groups: dict[str | None, _StartGroup], first_below: bool, held: set[str],
+ sha1s: dict[str, str],
+) -> list[str]:
+ """One block per system, as the section opens on them."""
+ keys = sorted(groups, key=lambda k: str(k).casefold())
+ blocks: dict[str | None, list[tuple[str, str]]] = {}
+ for key in keys:
+ block = _group_start(groups[key], first_below and not blocks)
+ if block and first_below and not blocks:
+ # "First below" holds only when the first file named is a card
+ # of this page: the Neo Geo BIOS is filed under Arcade.
+ first = re.search(r"`([^`]*)`", block[0][1])
+ if first and sha1s.get(first[1]) not in held:
+ block = _group_start(groups[key], False)
+ if block:
+ blocks[key] = block
if not blocks:
return []
if len(blocks) == 1:
return _start_lines(next(iter(blocks.values())))
+ if all(lead == "*" for block in blocks.values() for lead, _text in block):
+ # No machine of the section needs a file: the SNES said "Start with"
+ # over two lines that each said no file is needed.
+ lines = ["**No file needed** on any of these machines:", ""]
+ lines += [f"- **{key}**: {text.lstrip(': ')}"
+ for key, block in blocks.items() for _lead, text in block]
+ return [*lines, ""]
lines = ["**Start with** the files of your machine:", ""]
- for key in sorted(blocks, key=lambda k: str(k).casefold()):
- for lead, text in blocks[key]:
+ for key, block in blocks.items():
+ for lead, text in block:
if lead == "*":
lines.append(f"- **{key}**: no file needed{text}")
continue
@@ -5266,12 +5464,95 @@ def _start_with(
return [*lines, ""]
+def _display_names(emulators: set[str] | list[str]) -> list[str]:
+ return sorted({_emulator_display(e) for e in emulators}, key=str.casefold)
+
+
+def _names_counted(names: list[str], most: int = 3, noun: str = "") -> str:
+ """Names as "a, b and c", the ones past ``most`` counted: "a, b, c and
+ 12 more". One past ``most`` is named, not counted as "1 more"."""
+ if len(names) > most + 1:
+ more = f"{len(names) - most} more" + (f" {noun}" if noun else "")
+ return f"{', '.join(names[:most])} and {more}"
+ return _said_as_list(names)
+
+
+def _lead_file(groups: dict[str | None, _StartGroup]) -> list[str]:
+ """A section of several systems where most emulators require one file:
+ that file first, and the emulators that read another instead. The Neo
+ Geo section listed three machines, and neogeo.zip, which 26 emulators
+ read, came after a file ares alone reads."""
+ if len(groups) < 2:
+ return []
+ emulators: set[str] = set()
+ requires: dict[str, set[str]] = {}
+ written: dict[str, str] = {}
+ for group in groups.values():
+ emulators.update(group.emulators)
+ for visible, readers in group.requires.items():
+ requires.setdefault(visible, set()).update(readers)
+ written.setdefault(visible, group.written[visible])
+ if not requires:
+ return []
+ top = max(requires, key=lambda v: len(requires[v]))
+ readers = requires[top]
+ if 2 * len(readers) <= len(emulators):
+ return []
+ zipped = " Keep it zipped." if top.casefold().endswith(".zip") else ""
+ names = _display_names(readers)
+ said = (_names_counted(names, 2, "emulators") if len(names) > 2
+ else _said_as_list(names))
+ lines = [f"**Start with** {written[top]}.{zipped} {said} read{'s' if len(names) == 1 else ''} it.",
+ ""]
+ # The emulators that require other files and not this one, by the
+ # files they require.
+ others: dict[tuple[str, ...], set[str]] = {}
+ for emulator in emulators - readers:
+ theirs = tuple(written[v] for v in requires if emulator in requires[v])
+ if theirs:
+ others.setdefault(theirs, set()).add(emulator)
+ sentences = []
+ for files, who in sorted(others.items(), key=lambda item: (-len(item[1]), item[0])):
+ names = _display_names(who)
+ verb = "reads" if len(names) == 1 else "read"
+ sentences.append(f"{_names_counted(names)} {verb} {_said_as_list(list(files))} instead.")
+ if sentences:
+ lines += [" ".join(sentences), ""]
+ return lines
+
+
def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]:
"""One system's start as (lead, text) pairs. The lead is the regions a
line is for, "" the files every region needs, "*" the line of a system
- that runs without any file."""
+ that runs without any file.
+
+ A file is a start file when the emulators requiring it outweigh those
+ that run without it. The Game Boy opened on dmg_boot.bin, which Emux
+ GB alone of 13 requires, under a home page that says the Game Boy
+ needs no BIOS."""
from region import _PARENT # noqa: PLC0415
+ def listed(names: list[str], below: bool = first_below) -> str:
+ if len(names) <= START_WITH_NAMES:
+ return ", ".join(names)
+ # The first three by rank, the rest counted: "the 9 files" named
+ # none, and the Neo Geo BIOS everyone reads is filed under Arcade.
+ return f"{', '.join(names[:3])} and {_count(len(names) - 3, 'more file')}" + (
+ ", first below" if below else "")
+
+ requirers = group.requirers()
+ hle = group.hle - requirers
+ running = set(group.emulators) - requirers
+ # One that replaces the file with its own code runs, with less
+ # accuracy, and counts half: most PlayStation cores have such a
+ # fallback, and a reader still starts with the BIOS.
+ if requirers and 2 * len(requirers) <= 2 * len(running - hle) + len(hle):
+ free = _display_names(running)
+ needing = _display_names(requirers)
+ verb = "requires" if len(needing) == 1 else "require"
+ return [("*", f" with {_names_counted(free)}. Only {_names_counted(needing)} {verb} "
+ f"{listed(list(group.written.values()), False)}.")]
+
order = _region_order()
# A territory its region answers the same way adds nothing: Brazil
# under Latin America.
@@ -5287,14 +5568,6 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]
else:
regional[(*same, region)] = regional.pop(same)
- def listed(names: list[str]) -> str:
- if len(names) <= START_WITH_NAMES:
- return ", ".join(names)
- # The first three by rank, the rest counted: "the 9 files" named
- # none, and the Neo Geo BIOS everyone reads is filed under Arcade.
- return f"{', '.join(names[:3])} and {_count(len(names) - 3, 'more file')}" + (
- ", first below" if first_below else "")
-
out = [(", ".join(_region_word(r) for r in regions), listed(regional[regions]))
for regions in sorted(regional, key=lambda rs: (order.get(rs[0], len(order)), rs))]
# A name a region line gives already is no "also".
@@ -5305,6 +5578,11 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]
if not out and group.optional:
out.append(("*", f"{_without_files(group.emulators)}. "
f"For full accuracy: {listed(group.optional)}."))
+ elif not out and group.extra:
+ # The Mega Drive's emulators read the TMSS BIOS when it is there:
+ # the section said nothing under a home page saying none is needed.
+ out.append(("*", f"{_without_files(group.emulators)}. "
+ f"Optional: {listed(group.extra, False)}."))
return out
@@ -5351,11 +5629,12 @@ def _parts(cards: list[Card], budget: int, sizes: list[int] | None = None) -> li
return parts
-def _console_heading(manufacturer: str, console: str) -> str:
+def _console_heading(manufacturer: str, console: str, label: str = "") -> str:
"""A console's heading, its name alone. An icon on 79 of 544 headings
read as missing art on the rest, set the text at two positions on one
- page and faded out on one theme or the other."""
- return f"## {console} {{ #{console_anchor(manufacturer, console)} }}"
+ page and faded out on one theme or the other. ``label`` is the text
+ when it differs from the folder, the id staying the folder's."""
+ return f"## {label or console} {{ #{console_anchor(manufacturer, console)} }}"
def _romset_note(consoles: dict[str, list]) -> list[str]:
@@ -5374,6 +5653,146 @@ _NEXT_FROM_SYSTEMS = [
"[Every emulator](../emulators/index.md), and the files each one reads",
]
+# The arcade boards the system pages give a section, by the console key
+# their section goes by (the board's name) -> the board's system id. Set by
+# arcade_board_sections() before the first system page is written.
+_BOARD_CONSOLES: dict[str, str] = {}
+# The Arcade folder of files no board claims: the folder is named after
+# its maker, which said nothing about what it holds.
+OTHER_ARCADE = ("Arcade", "Arcade")
+OTHER_ARCADE_LABEL = "Other arcade files"
+
+
+def arcade_board_sections(
+ manufacturers: dict[str, dict[str, list]],
+ installs: dict[str, dict[str, list[tuple[str, str]]]],
+) -> tuple[dict[str, dict[str, list]], dict[str, dict[str, list[tuple[str, str]]]]]:
+ """The Arcade page grouped by board, and each board's install commands.
+
+ The Arcade sections were the folders of bios/Arcade/: NAOMI, PGM and
+ ZN-1 had no section, and their names stayed plain on 568 emulator
+ pages. Each archive goes to the board siterender.arcade_board() reads
+ from the profiles, never from its folder, and a folder named after a
+ board (Namco System 246) joins it. Files no board claims stay in their
+ folders. Each board is recorded (siterender.record_board_section) before
+ any page links one, and a system's install commands follow its link to
+ the board's section. Call after reset_console_pages()."""
+ from siterender import ( # noqa: PLC0415
+ arcade_board,
+ arcade_boards,
+ record_board_section,
+ system_section,
+ )
+
+ _BOARD_CONSOLES.clear()
+ arcade = manufacturers.get("Arcade")
+ if not arcade:
+ return manufacturers, installs
+ names = arcade_boards()
+ by_name = {name.casefold(): board for board, name in names.items()}
+ boards: dict[str, list] = {}
+ folders: dict[str, list] = {}
+ seen: dict[str, set[str]] = {}
+ for console in sorted(arcade):
+ joined = by_name.get(console.casefold())
+ for f in arcade[console]:
+ board = joined or arcade_board(str(f.get("sha1", "")))
+ if board is None:
+ folders.setdefault(console, []).append(f)
+ continue
+ # One card per content: a copy under Arcade and one under MAME
+ # are the same file.
+ if str(f.get("sha1", "")) in seen.setdefault(board, set()):
+ continue
+ seen[board].add(str(f.get("sha1", "")))
+ boards.setdefault(board, []).append(f)
+ for board in sorted(boards):
+ record_board_section(board)
+ _BOARD_CONSOLES[names[board]] = board
+ regrouped = {**folders, **{names[board]: files for board, files in boards.items()}}
+
+ moved: dict[str, dict[str, list[tuple[str, str]]]] = {}
+ for slug, consoles in installs.items():
+ for console, pairs in consoles.items():
+ for platform, sys_id in pairs:
+ section = system_section(sys_id)
+ if section and section[0] == "arcade" and section[1] in _BOARD_CONSOLES:
+ slug_to, console_to = section
+ else:
+ slug_to, console_to = slug, console
+ moved.setdefault(slug_to, {}).setdefault(console_to, []).append((platform, sys_id))
+ return {**manufacturers, "Arcade": regrouped}, moved
+
+
+def _section_label(manufacturer: str, console: str) -> str:
+ """The heading a section shows."""
+ return OTHER_ARCADE_LABEL if (manufacturer, console) == OTHER_ARCADE else console
+
+
+def _part_name(manufacturer: str, console: str) -> str:
+ """What a part's title calls the files: "Arcade files a to b"."""
+ return "Arcade" if (manufacturer, console) == OTHER_ARCADE else console
+
+
+def _system_front_matter(
+ parent: tuple[str, str] | None = None,
+ prev: tuple[str, str] | None = None,
+ following: tuple[str, str] | None = None,
+ weight: float | None = None,
+ consoles: list[str] | None = None,
+) -> list[str]:
+ """The front matter of a system page: the page it belongs to, the
+ parts before and after it (label, site path), its search weight and
+ the consoles it holds, which the search reads as a field."""
+ lines = _parent_front_matter(parent)
+ for key, link in (("prev", prev), ("next", following)):
+ if link:
+ lines += [f"{key}:", f" label: {json.dumps(link[0])}", f" link: {json.dumps(link[1])}"]
+ if weight is not None:
+ lines.append(f"searchWeight: {weight}")
+ if consoles:
+ lines += ["consoles:", *(f" - {json.dumps(name)}" for name in consoles)]
+ return ["---", *lines, "---", ""] if lines else []
+
+
+def _part_weight() -> float:
+ """The search weight of a part page: its cards keep their own weight."""
+ return SEARCH_WEIGHT.get("part", DEMOTED_WEIGHT)
+
+
+@functools.lru_cache(maxsize=None)
+def _manifest_systems(platform: str) -> frozenset[str]:
+ """The system ids a platform's install manifest records: --system
+ refuses any other."""
+ manifest = _REPO_ROOT / "install" / f"{platform}.json"
+ if not manifest.is_file():
+ return frozenset()
+ document = json.loads(manifest.read_text(encoding="utf-8"))
+ return frozenset(
+ str(system)
+ for entry in (document.get("files") or []) + (document.get("omitted_files") or [])
+ for system in entry.get("systems") or []
+ )
+
+
+def _install_pairs(
+ declared: list[tuple[str, str]], lead: str | None, consumers: FileConsumers,
+) -> list[tuple[str, str]]:
+ """A section's install commands: the setups whose system leads here,
+ then each setup that files the section's first file under a system of
+ its own (RetroArch files neogeo.zip under arcade), with that id."""
+ pairs = list(declared)
+ have = {platform for platform, _system in pairs}
+ for platform, systems in sorted(getattr(consumers, "platform_systems", {})
+ .get(lead or "", {}).items()):
+ if platform in have:
+ continue
+ recorded = [s for s in sorted(systems) if s in _manifest_systems(platform)]
+ if recorded:
+ pairs.append((platform, recorded[0]))
+ have.add(platform)
+ return sorted(pairs, key=lambda pair: (_platform_display(pair[0]).casefold(), pair[1]))
+
def system_pages(
manufacturer: str,
@@ -5390,43 +5809,85 @@ def system_pages(
systems//# still lands, and site.js sends a card
anchor the page no longer holds to the card's new place. The pages
each card and console went to are recorded for every other link
- (siterender.record_console_pages).
+ (siterender.record_console_pages). An arcade board has a page of its
+ own, systems/arcade-/, under a heading of that id.
"""
+ from siterender import board_slug # noqa: PLC0415
+
slug = _slug(manufacturer)
- json_link = (f"[JSON](../api/v1/systems/{urllib.parse.quote(slug)}.json)")
+ installs = installs or {}
+ boards = {console: _BOARD_CONSOLES[console] for console in consoles
+ if manufacturer == "Arcade" and console in _BOARD_CONSOLES}
+ json_next = (f"[Every hash of these files, as JSON](../api/v1/systems/"
+ f"{urllib.parse.quote(slug)}.json)")
+ # Boards first on the Arcade page, then the folders of sets for many
+ # machines. Such a folder starts nowhere: its line named konamigv.zip,
+ # sys573.zip and 97 more, files of boards with lines of their own.
+ ordered = sorted(consoles, key=lambda c: (bool(boards) and c not in boards, c))
+
+ def start_of(console: str, first_below: bool = True) -> list[str]:
+ if boards and console not in boards:
+ return []
+ return _start_with(consoles[console], consumers, first_below, (manufacturer, console))
+
+ starts = {console: [] if boards and console not in boards
+ else _start_order(consoles[console], consumers, (manufacturer, console))
+ for console in consoles}
+
+ def cards_of(console: str, anchors: set[str], by_name: bool = False) -> list[Card]:
+ return _console_cards(manufacturer, consoles[console], consumers, anchors,
+ by_name, starts[console])
+
+ def install_block(console: str) -> list[str]:
+ lead = starts[console][0] if starts[console] else None
+ return _console_install_block(
+ _section_label(manufacturer, console),
+ _install_pairs(installs.get(console, []), lead, consumers))
+
def all_sections() -> dict[str, list[Card]]:
anchors: set[str] = set()
- return {console: _console_cards(manufacturer, consoles[console], consumers, anchors)
- for console in sorted(consoles)}
+ return {console: cards_of(console, anchors) for console in ordered}
+
+ # One line, then the legend closed: four paragraphs said the same on
+ # every maker's page before its first console.
+ intro = f"Every BIOS file for {manufacturer} machines, one card per file."
+ legend = search_excluded(markup.details("note", "How to read a card", [
+ "A card gives the file's fingerprints, the emulators that read it, the setups "
+ "that list it and where to download it. A file can have several versions, one "
+ "per region or revision: the main one comes first, the others under "
+ "\"Variants\". To install the files of one console, open its \"Install\" box.",
+ "",
+ "**On the setup's list** names the setups whose own BIOS list has the file, by "
+ "the name each gives it. **Packed for its emulators by** names the setups whose "
+ "pack adds it because one of their emulators reads it.",
+ ]))
+ # The consoles a page answers for, a search field: machines only. An
+ # emulator's folder under Other put that page first for "dolphin", and
+ # a console with a page of its own is that page's to answer for.
+ machines = [console for console in ordered
+ if manufacturer not in ("Other", *GAME_DATA_TOPS)
+ and (manufacturer != "Arcade" or console in boards)]
+
+ def consoles_front(kept: set[str] | None = None) -> list[str]:
+ return _system_front_matter(consoles=[
+ _section_label(manufacturer, c) for c in machines if kept is None or c in kept])
+
+ front = consoles_front()
- intro = (
- f"Every BIOS file the collection holds for {manufacturer} machines, one "
- "card per file. A card gives the file's fingerprints, the emulators that "
- "read it, the setups that list it and where to download it. A file can "
- "have several versions, one per region or revision: the main one comes "
- "first, the others under \"Variants\". To install the files of one "
- "console, open its \"Install\" box."
- )
- # The two setup lines of a card differ, and only a code comment said how.
- key = (
- "On a card, **On the setup's list** names the setups whose own BIOS "
- "list has the file. **Packed for its emulators by** names the setups "
- "whose pack adds it because one of their emulators reads it."
- )
def whole_page(sections: dict[str, list[Card]]) -> str:
lines = [
- f"# {manufacturer}", "", intro, "", key, "", *_romset_note(consoles),
- f"The files below by console, with every hash, are also published as {json_link}.",
- "", *_icon_symbol(),
+ *front, f"# {manufacturer}", "", intro, "", *legend, *_romset_note(consoles),
+ *_icon_symbol(),
]
for console, cards in sections.items():
- lines.append(_console_heading(manufacturer, console))
+ lines.append(_console_heading(manufacturer, console,
+ _section_label(manufacturer, console)))
lines.append("")
- lines.extend(_start_with(consoles[console], consumers, section=(manufacturer, console)))
- lines.extend(_console_install_block(console, (installs or {}).get(console, [])))
+ lines.extend(start_of(console))
+ lines.extend(install_block(console))
lines.extend(_cards_markdown(cards))
lines.append("")
- lines.extend(_next_block(_NEXT_FROM_SYSTEMS))
+ lines.extend(_next_block([*_NEXT_FROM_SYSTEMS, json_next]))
lines.extend(["", _date_line()])
return "\n".join(lines) + "\n"
@@ -5447,18 +5908,20 @@ def system_pages(
# page, which the card weights never counted. A page is also cut when
# its estimate as it renders passes the summary's.
with markup.using("mkdocs"):
- boxes = {console: sum(len(line) + 1 for line in _console_install_block(
- console, (installs or {}).get(console, []))) for console in consoles}
+ boxes = {console: sum(len(line) + 1 for line in install_block(console))
+ for console in consoles}
rendered = {console: CARD_HTML_RATIO * weights[console] + INSTALL_HTML_RATIO * boxes[console]
for console in weights}
- if len(measured_text) <= budget and sum(rendered.values()) <= SYSTEM_SUMMARY_HTML:
+ if (not boards and len(measured_text) <= budget
+ and sum(rendered.values()) <= SYSTEM_SUMMARY_HTML):
text = whole_page(all_sections()) if markup.starlight() else measured_text
return {slug: (text, consoles)}
- # The heaviest sections move to pages of their own until what stays on
- # the manufacturer's page is half the budget: a section of one file
- # keeps its place, and a reader does not open 91 pages to browse Other.
- moved: set[str] = set()
+ # Every board has a page of its own. Then the heaviest sections move
+ # to pages of their own until what stays on the manufacturer's page is
+ # half the budget: a section of one file keeps its place, and a reader
+ # does not open 91 pages to browse Other.
+ moved: set[str] = set(boards)
for console in sorted(weights, key=lambda c: (-weights[c], c)):
if sum(w for c, w in weights.items() if c not in moved) <= budget // 2:
break
@@ -5471,96 +5934,136 @@ def system_pages(
pages: dict[str, tuple[str, dict[str, list]]] = {}
summary = [
- f"# {manufacturer}", "", intro, "", key, "", *_romset_note(consoles),
- f"The files below by console, with every hash, are also published as {json_link}. "
- f"The largest sections have a page of their own, linked from their heading.",
- "", *_icon_symbol(),
+ *consoles_front(set(consoles) - moved), f"# {manufacturer}", "", intro, "", *legend,
+ *_romset_note(consoles),
+ *_icon_symbol(),
]
summary_anchors: set[str] = set()
- for console in sorted(consoles):
- summary.append(_console_heading(manufacturer, console))
+ parent = (manufacturer, f"/systems/{slug}/")
+ for console in ordered:
+ label = _section_label(manufacturer, console)
+ summary.append(_console_heading(manufacturer, console, label))
summary.append("")
if console not in moved:
- summary.extend(_start_with(consoles[console], consumers, section=(manufacturer, console)))
- summary.extend(_console_install_block(console, (installs or {}).get(console, [])))
- summary.extend(_cards_markdown(
- _console_cards(manufacturer, consoles[console], consumers, summary_anchors)))
+ summary.extend(start_of(console))
+ summary.extend(install_block(console))
+ summary.extend(_cards_markdown(cards_of(console, summary_anchors)))
summary.append("")
continue
# Cards are rendered again for their own page: the anchors a page
# holds are its own.
def cut(by_name: bool) -> list[list[Card]]:
- cards = _console_cards(manufacturer, consoles[console], consumers, set(), by_name)
+ cards = cards_of(console, set(), by_name)
with markup.using("mkdocs"):
- sizes = [card_weight(card) for _variant, card, _sha1 in _console_cards(
- manufacturer, consoles[console], consumers, set(), by_name)]
- return _parts(cards, budget, sizes)
+ sizes = [card_weight(card) for _variant, card, _sha1 in cards_of(
+ console, set(), by_name)]
+ parts = _parts(cards, budget, sizes)
+ if len(parts) < 2:
+ return parts
+ # Even parts: cut at budget, the PlayStation 2 was 137 cards
+ # and 4. The same number of parts, each near the mean.
+ share = -(-sum(sizes) // len(parts))
+ even = _parts(cards, share, sizes)
+ while len(even) > len(parts):
+ share += max(1, share // 50)
+ even = _parts(cards, share, sizes)
+ return even
# One page keeps the required files first. Parts are named by their
- # first and last file, so a console cut in parts goes by name.
+ # first and last file, so a console cut in parts goes by name, the
+ # files the start line names first.
parts = cut(False)
if len(parts) > 1:
parts = cut(True)
- base = f"{slug}-{_slugify_anchor(console) or 'files'}"
+ board = boards.get(console)
+ base = (f"arcade-{board_slug(board)}" if board
+ else f"{slug}-{_slugify_anchor(console) or 'files'}")
stems = [base if index == 0 else f"{base}-{index + 1}" for index in range(len(parts))]
+ clash = set(stems) & set(pages)
+ if clash:
+ raise RuntimeError(f"two system sections take one page: {sorted(clash)}")
record_console_pages(slug, console, stems, [
[sha1 for _variant, _card, sha1 in part] for part in parts
])
- title = f"{manufacturer}: {console}"
+ lead = set(starts[console])
+ # A part by the files it holds: "Arcade: Arcade, part 3 of 5" said
+ # nothing a search result could use.
+ titles = ([f"{manufacturer}: {label}"] if len(stems) == 1 else [
+ "{} files {} to {}".format(_part_name(manufacturer, console), *_part_ends(part, lead))
+ for part in parts])
count = _count(len(consoles[console]), "file")
- summary.extend(_start_with(consoles[console], consumers, first_below=False,
- section=(manufacturer, console)))
+ summary.extend(start_of(console, False))
if len(stems) == 1:
- summary.append(f"{count}, on [their own page]({stems[0]}.md).")
+ own = "its" if len(consoles[console]) == 1 else "their"
+ summary.append(f"{count}, on [{own} own page]({stems[0]}.md).")
else:
summary.append(f"{count}, on {len(stems)} pages by file name:")
summary.append("")
for index, (stem, part) in enumerate(zip(stems, parts)):
- summary.append(f"- [Part {index + 1}]({stem}.md): {_part_range(part)}")
+ summary.append(f"- [Part {index + 1}]({stem}.md): {_part_range(part, lead)}")
summary.append("")
for index, (stem, part) in enumerate(zip(stems, parts)):
- heading = title if len(stems) == 1 else f"{title}, part {index + 1} of {len(stems)}"
+ neighbours = [(titles[i], f"/systems/{stems[i]}/")
+ for i in (index - 1, index + 1) if 0 <= i < len(stems)]
lines = [
- f"# {heading}", "",
- f"Part of [{manufacturer}]({slug}.md), in [Systems](index.md). "
- f"Every hash of these files is also published as {json_link}.",
+ *_system_front_matter(
+ parent,
+ prev=neighbours[0] if index > 0 else None,
+ following=neighbours[-1] if index + 1 < len(stems) else None,
+ weight=_part_weight() if len(stems) > 1 else None,
+ consoles=[label] if index == 0 and console in machines else None),
+ f"# {titles[index]}", "",
+ f"Part of [{manufacturer}]({slug}.md), in [Systems](index.md).",
"", *_icon_symbol(),
]
if len(stems) > 1:
others = ", ".join(
f"[part {i + 1}]({other}.md)" for i, other in enumerate(stems) if i != index)
- lines.extend([f"This part holds {_part_range(part)}. The others: {others}.", ""])
+ lines.extend([f"This part holds {_part_range(part, lead)}. The others: {others}.",
+ ""])
+ if board:
+ # The heading a board's links land on, above its start line.
+ lines.extend([f"## {label} {{ #{board_slug(board)} }}", ""])
if index == 0:
- lines.extend(_start_with(consoles[console], consumers, first_below=len(stems) == 1,
- section=(manufacturer, console)))
- lines.extend([key, ""])
+ lines.extend(start_of(console, len(stems) == 1))
+ lines.extend(legend)
lines.extend(_romset_note({console: consoles[console]}))
- lines.extend(_console_install_block(console, (installs or {}).get(console, [])))
- lines.extend(["## Files", ""])
+ lines.extend(install_block(console))
+ if not board:
+ lines.extend(["## Files", ""])
lines.extend(_cards_markdown(part))
lines.append("")
lines.extend(_next_block([f"[{manufacturer}]({slug}.md), every section"]
- + _NEXT_FROM_SYSTEMS))
+ + _NEXT_FROM_SYSTEMS + [json_next]))
lines.extend(["", _date_line()])
held = {sha1 for _variant, _card, sha1 in part}
pages[stem] = ("\n".join(lines) + "\n", {console: [
f for f in consoles[console] if str(f.get("sha1", "")) in held]})
- summary.extend(_next_block(_NEXT_FROM_SYSTEMS))
+ summary.extend(_next_block([*_NEXT_FROM_SYSTEMS, json_next]))
summary.extend(["", _date_line()])
kept = {console: files for console, files in consoles.items() if console not in moved}
return {slug: ("\n".join(summary) + "\n", kept), **pages}
-def _part_range(part: list[Card]) -> str:
- """The first and last file names of a part, as the cards show them."""
+def _part_ends(part: list[Card], lead: set[str] | frozenset[str] = frozenset()) -> tuple[str, str]:
+ """The first and last file names of a part, as the cards show them.
+ The files a start line puts first are left out when the part holds
+ others: the range is the run by name that follows them."""
+ named = [card for card in part if card[2] not in lead] or part
names = []
- for _variant, card, _sha1 in part:
+ for _variant, card, _sha1 in named:
# An HTML heading on the site, a Markdown one in the size yardstick.
match = re.search(r"`([^`]*)`|(.*?)", card[2])
names.append(match[1] if match[1] is not None else html.unescape(match[2]))
- if len(names) == 1:
- return f"`{names[0]}`"
- return f"{_count(len(names), 'file')}, `{names[0]}` to `{names[-1]}`"
+ return names[0], names[-1]
+
+
+def _part_range(part: list[Card], lead: set[str] | frozenset[str] = frozenset()) -> str:
+ """The first and last file names of a part, with its count."""
+ first, last = _part_ends(part, lead)
+ if len(part) == 1:
+ return f"`{first}`"
+ return f"{_count(len(part), 'file')}, `{first}` to `{last}`"
def generate_system_page(
@@ -6350,14 +6853,18 @@ def _ps_quote(value: str) -> str:
def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]:
"""Each platform's command for one console, by the system id it gives it,
- for a terminal and for PowerShell."""
+ for a terminal and for PowerShell, each under its setup's name: RetroBat
+ had two lines and nothing said which was for the Neo Geo."""
if not declared:
return []
- sh = [install_command("sh", f"--platform {platform} --system {shlex.quote(system)}")
- for platform, system in declared]
- ps = [install_command("powershell", f"--platform {platform} --system {_ps_quote(system)}")
- for platform, system in declared]
- return search_excluded(markup.details("tip", f"Install the {console} files", [
+ sh = [line for platform, system in declared for line in (
+ f"# {_platform_display(platform)}",
+ install_command("sh", f"--platform {platform} --system {shlex.quote(system)}"))]
+ ps = [line for platform, system in declared for line in (
+ f"# {_platform_display(platform)}",
+ install_command("powershell", f"--platform {platform} --system {_ps_quote(system)}"))]
+ # The name last: "Install the Atari System 1 files" read as a count.
+ return search_excluded(markup.details("tip", f"Install the files of {console}", [
"Each platform names the system its own way.",
"",
"Linux, macOS, Steam Deck:",
@@ -9036,10 +9543,6 @@ def _generate(args: argparse.Namespace) -> None:
set_console_sections(manufacturers, program_names(profiles))
from siterender import link_coverage, system_ids # noqa: PLC0415
- linked, total = link_coverage(
- system_ids(profiles, [cov["config"] for cov in coverages.values()]))
- print(f" {linked} of {total} system ids linked")
-
print("Generating system pages...")
prime_system_icons({
@@ -9048,14 +9551,21 @@ def _generate(args: argparse.Namespace) -> None:
for console in consoles
})
+ # The arcade boards are recorded before any page links one, the
+ # system pages first among them.
+ reset_console_pages()
+ sections, console_installs = arcade_board_sections(
+ manufacturers, system_install_choices(coverages))
+ linked, total = link_coverage(
+ system_ids(profiles, [cov["config"] for cov in coverages.values()]))
+ print(f" {linked} of {total} system ids linked")
+
write_if_changed(
- str(docs / "systems" / "index.md"), generate_systems_index(manufacturers)
+ str(docs / "systems" / "index.md"), generate_systems_index(sections)
)
- console_installs = system_install_choices(coverages)
(docs / "files").mkdir(parents=True, exist_ok=True)
write_if_changed(str(docs / "files" / "index.md"), generate_files_page())
- reset_console_pages()
- for mfr, consoles in manufacturers.items():
+ for mfr, consoles in sections.items():
slug = mfr.lower().replace(" ", "-")
pages = system_pages(mfr, consoles, consumers, console_installs.get(slug))
for stem, (page, held) in pages.items():
@@ -9065,7 +9575,8 @@ def _generate(args: argparse.Namespace) -> None:
write_if_changed(str(docs / "systems" / f"{stem}.md"), page)
write_single_item(
public / "api" / "v1" / "systems" / f"{slug}.json", "systems",
- system_export_item(slug, mfr, consoles),
+ # The published data keeps the collection's folders.
+ system_export_item(slug, mfr, manufacturers[mfr]),
str(db.get("generated_at") or _timestamp()), _source_commit(),
)
clash = split_system_stems() & {_slug(m) for m in manufacturers}