feat: round 3 system pages

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 11:02:30 +02:00
commit c1cd15c10d
96 files changed
+822 -82

No files matched your search

+438 -60
View File
@@ -31,6 +31,7 @@ import sys
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from collections.abc import Callable, Iterator
from datetime import datetime, timezone
from pathlib import Path
@@ -3587,14 +3588,19 @@ def generate_systems_index(manufacturers: dict) -> str:
]
# Other holds one folder per emulator: a7800 or altirra in the console
# table named no console.
# table named no console. Arcade boards are neither consoles nor
# computers, and the crumb of their page says Arcade too.
def kind(mfr: str) -> str:
if mfr in GAME_DATA_TOPS:
return "games"
if mfr == "Arcade":
return "arcade"
return "programs" if mfr == "Other" else "consoles"
for heading, group, first, note in (
("Consoles and computers", "consoles", "Console", ""),
("Arcade", "arcade", "Name",
"Arcade boards, and the sets each arcade emulator family reads."),
("Emulator folders", "programs", "Folder",
"Files kept under the emulator that reads them, not under a console."),
("Game engines and PC games", "games", "Name",
@@ -4063,6 +4069,116 @@ def _icon_symbol() -> list[str]:
return [PERMALINK_SYMBOL, ""] if markup.starlight() else []
RomsetMembers = tuple[tuple[str, str], ...]
# A release a recipe agrees with: its place among the imported releases of
# its source, and its name ("0.220", "1.0.0.03").
Release = tuple[int, str]
def _release_name(source: str, label: str, versions: dict) -> str:
""""MAME mame0220" as "0.220", an FBNeo DAT title as its version."""
match = re.fullmatch(r"MAME mame(\d)(\d+)", label) if source == "mame" else None
return f"{match[1]}.{match[2]}" if match else str(versions.get(label) or label)
@functools.lru_cache(maxsize=1)
def _romset_snapshots() -> list[tuple[str, dict]]:
return [(str(snapshot.get("source", path.stem)), snapshot)
for path in sorted((_REPO_ROOT / "recipes").glob("*.json"))
for snapshot in [json.loads(path.read_text(encoding="utf-8"))]]
@functools.lru_cache(maxsize=1)
def _romset_releases() -> dict[str, list[tuple[RomsetMembers, str, list[Release]]]]:
"""Archive name -> (members, source, the releases agreeing on them), from
the MAME and FBNeo recipe snapshots under recipes/.
A snapshot stores a recipe once for every version that agrees on it,
so a match reads "this set's contents in those versions".
"""
releases: dict[str, list[tuple[RomsetMembers, str, list[Release]]]] = {}
for source, snapshot in _romset_snapshots():
versions = snapshot.get("dats") or {}
order = {label: index for index, label in enumerate(versions)}
for entry in snapshot.get("entries") or []:
members = tuple(sorted(
(str(m["name"]), str(m.get("crc32") or "").lower())
for m in entry.get("members") or [] if m.get("name") is not None
))
if not entry.get("name") or not members or not all(crc for _n, crc in members):
continue
agreed = [(order.get(label, -1), _release_name(source, str(label), versions))
for label in entry.get("dats") or [entry.get("dat")]]
releases.setdefault(str(entry["name"]), []).append((members, source, agreed))
return releases
def _release_runs(releases: list[Release]) -> str:
"""Releases as runs of consecutive imports: "0.190 to 0.195, 0.220 to
0.289". One FBNeo version shared by 15 DATs reads once."""
runs: list[list[Release]] = []
for release in sorted(set(releases)):
if runs and release[1] == runs[-1][-1][1]:
continue
if runs and release[0] == runs[-1][-1][0] + 1:
runs[-1].append(release)
else:
runs.append([release])
return ", ".join(run[0][1] if len(run) == 1 else f"{run[0][1]} to {run[-1][1]}"
for run in runs)
def _source_order(source: str) -> tuple[bool, str]:
return source != "mame", source
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.
"""
recipes = _romset_releases().get(str(f.get("name", "")))
path = _REPO_ROOT / str(f.get("path", ""))
if not recipes or not path.is_file():
return ""
try:
with zipfile.ZipFile(path) as archive:
held = tuple(sorted((info.filename, f"{info.CRC:08x}")
for info in archive.infolist() if not info.is_dir()))
except (OSError, zipfile.BadZipFile) as exc:
print(f" WARNING: {path}: {exc}", file=sys.stderr)
return ""
matched: dict[str, list[Release]] = {}
for members, source, agreed in recipes:
if members == held:
matched.setdefault(source, []).extend(agreed)
if not matched:
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))
@functools.lru_cache(maxsize=1)
def _romset_coverage() -> str:
"""The releases the recipes cover: "MAME 0.190 to 0.289 or FBNeo 1.0.0.03"."""
covered = {}
for source, snapshot in _romset_snapshots():
versions = snapshot.get("dats") or {}
if versions:
ends = [(index, _release_name(source, label, versions))
for index, label in enumerate(versions)]
# The ends of the span, read as one run.
span = [ends[0], (1, ends[-1][1])] if len(ends) > 1 else ends
covered[source] = f"{_ROMSET_SOURCES.get(source, source)} {_release_runs(span)}"
return " or ".join(covered[source] for source in sorted(covered, key=_source_order))
ROMSET_FAQ = "../wiki/technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match"
def _system_file_card(
f: dict, consumers: FileConsumers, anchored: set[str] | None = None,
searchable: bool = True,
@@ -4128,6 +4244,11 @@ def _system_file_card(
facts.append(("Packed for its emulators by", [_places_text(installed)], False))
if emus:
facts.append(("Emulators", [", ".join(_emulator_links(sha1_full, consumers))], False))
romset = _romset_line(f)
if romset:
# The version question every arcade player asks, answered from the
# recipes: the cards named emulators and no release.
facts.append(("Romset", [romset], False))
if f.get("path"):
# The card named a file, its hashes and provenance, but not where to
# get it: a user missing scph5501.bin found the reference and no way
@@ -4319,6 +4440,14 @@ def system_install_choices(coverages: dict) -> dict[str, dict[str, list[tuple[st
# stays under 600 KB from 300 KB. game-engines was one 10.5 MB page that
# took 21 s of main-thread work on a phone.
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.
CARD_HTML_RATIO = 1.7
INSTALL_HTML_RATIO = 9
SYSTEM_SUMMARY_HTML = 500_000
# A system card: whether it is a variant, its lines, its sha1.
Card = tuple[bool, list[str], str]
@@ -4341,17 +4470,22 @@ _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 by how many setups list the file, then by name.
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.
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.
"""
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 (
not any(e.get("required", True) for e in entries),
not required,
min(priorities, default=_NO_PRIORITY),
-len(entries) if required else 0,
-len(consumers.platforms.get(sha1, ())),
f["name"].casefold(), f["name"], f.get("path", ""),
)
@@ -4403,32 +4537,241 @@ def _region_word(region: str) -> str:
return _REGION_WORDS.get(region) or region.replace("-", " ").title()
def _start_with(files: list[dict], consumers: FileConsumers, first_below: bool = True) -> list[str]:
"""The line a console section opens on: the files an emulator requires,
by the region the emulator picks each for.
def _reading_pairs(sha1: str, consumers: FileConsumers) -> list[tuple[str, dict]]:
"""The profile entries that read a file, each with its emulator."""
files_of = getattr(consumers, "emulator_files", {})
pairs = []
for emulator, index in sorted(getattr(consumers, "emulator_entries", {}).get(sha1, {}).items()):
files = files_of.get(emulator) or []
if 0 <= index < len(files) and isinstance(files[index], dict):
pairs.append((emulator, files[index]))
return pairs
Read from the profiles' required and region fields, never typed per
system. A section where no emulator requires a file gets no line.
@functools.lru_cache(maxsize=1)
def _profiles_on_disk() -> dict[str, dict]:
from common import load_emulator_profiles # noqa: PLC0415
return load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False)
def _entry_systems(emulator: str, entry: dict) -> list[str]:
"""The system an entry is read for: its own, or its profile's only one."""
if entry.get("system"):
return [str(entry["system"])]
systems = (_profiles_on_disk().get(emulator) or {}).get("systems") or []
return [str(systems[0])] if len(systems) == 1 else []
def _emulator_display(emulator: str) -> str:
return str((_profiles_on_disk().get(emulator) or {}).get("emulator") or emulator)
# id(consumers) -> (consumers, section -> the entries whose system leads there).
_SECTION_ENTRIES: dict[int, tuple[object, dict[tuple[str, str], list]]] = {}
def _entries_by_section(consumers: FileConsumers) -> dict[tuple[str, str], list]:
"""Each console section with the (sha1, emulator, entry, system) whose
system leads to it, read once per build."""
from siterender import system_section # noqa: PLC0415
cached = _SECTION_ENTRIES.get(id(consumers))
if cached is not None and cached[0] is consumers:
return cached[1]
sections: dict[str, tuple[str, str] | None] = {}
index: dict[tuple[str, str], list] = {}
for sha1 in sorted(getattr(consumers, "emulator_entries", {})):
for emulator, entry in _reading_pairs(sha1, consumers):
for sys_id in _entry_systems(emulator, entry):
if sys_id not in sections:
sections[sys_id] = system_section(sys_id)
place = sections[sys_id]
if place is not None:
index.setdefault(place, []).append((sha1, emulator, entry, sys_id))
_SECTION_ENTRIES.clear()
_SECTION_ENTRIES[id(consumers)] = (consumers, index)
return index
def _setup_name(f: dict, consumers: FileConsumers, pairs: list[tuple[str, dict]]) -> str:
"""The name most setups give a file, else most emulators: the collection
keeps BizHawk's GBA_bios.rom where eight setups write gba_bios.bin."""
counts: dict[str, int] = {}
destinations = getattr(consumers, "platform_destinations", {}).get(str(f.get("sha1", "")), {})
for dests in destinations.values():
for base in {d.replace("\\", "/").rsplit("/", 1)[-1] for d in dests if d}:
counts[base] = counts.get(base, 0) + 1
if not counts:
for _emulator, entry in pairs:
if entry.get("name"):
name = str(entry["name"])
counts[name] = counts.get(name, 0) + 1
if not counts:
return f["name"]
most = max(counts.values())
top = sorted((n for n, c in counts.items() if c == most), key=lambda n: (n.casefold(), n))
return f["name"] if f["name"] in top else top[0]
def _without_files(emulators: list[str]) -> str:
"""Who runs without the files: every emulator reading one, up to three
by name, more counted, as their order would rank nothing."""
names = sorted({_emulator_display(e) for e in emulators}, key=str.casefold)
if len(names) > 3:
return f": none of its {len(names)} emulators requires one"
return " with " + (", ".join(names[:-1]) + f" or {names[-1]}" if len(names) > 1 else names[0])
def _visible(name: str) -> str:
"""The file name a start line shows, out of its code span and link."""
match = re.match(r"\[?`([^`]*)`", name)
return match[1] if match else name
def _add_name(names: list[str], name: str) -> None:
"""A name once per line: two copies of sfcbox.zip read as one file."""
if all(_visible(n) != _visible(name) for n in names):
names.append(name)
class _StartGroup:
"""What one system of a section asks for."""
def __init__(self) -> None:
self.by_region: dict[str, list[str]] = {}
self.plain: list[str] = []
# Files an emulator reads when present and replaces when absent.
self.optional: list[str] = []
self.emulators: list[str] = []
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)):
for name in theirs:
_add_name(mine, name)
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."""
from siterender import _heading_name, _manufacturer_slug, system_name, system_section # noqa: PLC0415
here = (_manufacturer_slug(section[0]), section[1]) if section else None
held = {str(f.get("sha1", "")) for f in files}
primaries = [f for f in files if "/.variants/" not in f["path"]]
kept: dict[str, list[tuple[str, dict, str | None]]] = {}
for f in primaries:
sha1 = str(f.get("sha1", ""))
for emulator, entry in _reading_pairs(sha1, consumers):
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()):
continue
kept.setdefault(sha1, []).append((emulator, entry, system))
candidates = list(primaries)
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("/")
if (sha1 in held or not record or entry.get("bundled") or ".variants" in parts
or len(parts) < 3 or parts[1] != "Arcade"):
continue
if sha1 not in kept:
candidates.append({**record, "elsewhere": True})
kept.setdefault(sha1, []).append((emulator, entry, system))
groups: dict[str | None, _StartGroup] = {}
for f in sorted(candidates, key=lambda f: _card_rank(f, consumers)):
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:
try:
key = system_name(system) if system else None
except KeyError:
key = None
group = groups.setdefault(key, _StartGroup())
if emulator not in group.emulators:
group.emulators.append(emulator)
if entry.get("hle_fallback"):
_add_name(group.optional, name)
continue
if not entry.get("required", True):
continue
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)
named = [key for key in groups if key is not None]
if None in groups and named:
# An entry that names no system reads the section's own console.
heading = _heading_name(*section) if section else None
groups[heading if heading in groups else named[0]].absorb(groups.pop(None))
return groups
def _start_with(
files: list[dict], consumers: FileConsumers, first_below: bool = True,
section: tuple[str, str] | None = None,
) -> list[str]:
"""The line a console section opens on: the files an emulator requires,
by the region the emulator picks each for, and by system when the
section holds several.
``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.
"""
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}
if not blocks:
return []
if len(blocks) == 1:
return _start_lines(next(iter(blocks.values())))
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]:
if lead == "*":
lines.append(f"- **{key}**: no file needed{text}")
continue
lines.append(f"- **{key}, {lead}**: {text}" if lead else f"- **{key}**: {text}")
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."""
from region import _PARENT # noqa: PLC0415
order = _region_order()
by_region: dict[str, list[str]] = {}
plain: list[str] = []
primaries = [f for f in files if "/.variants/" not in f["path"]]
for f in sorted(primaries, key=lambda f: _card_rank(f, consumers)):
required = [e for e in _reading_entries(str(f.get("sha1", "")), consumers)
if e.get("required", True)]
if not required:
continue
regions = {str(r) for e in required for r in e.get("region") or []}
for names in [by_region.setdefault(r, []) for r in regions] or [plain]:
if f["name"] not in names:
names.append(f["name"])
# A territory its region answers the same way adds nothing: Brazil
# under Latin America.
by_region = {r: names for r, names in by_region.items()
if by_region.get(_PARENT.get(r, "")) != names}
by_region = {r: names for r, names in group.by_region.items()
if group.by_region.get(_PARENT.get(r, "")) != names}
# Regions that need the same files share a line.
regional: dict[tuple[str, ...], list[str]] = {}
for region in sorted(by_region, key=lambda r: (order.get(r, len(order)), r)):
@@ -4438,27 +4781,40 @@ def _start_with(files: list[dict], consumers: FileConsumers, first_below: bool =
regional[(region,)] = names
else:
regional[(*same, region)] = regional.pop(same)
if not regional and not plain:
return []
def named(names: list[str], lead: str) -> str:
def listed(names: list[str]) -> str:
if len(names) <= START_WITH_NAMES:
return f"{lead}: {', '.join(_code(n) for n in names)}"
where = ", first below" if first_below else ""
return f"{lead}: {_count(len(names), 'more file' if regional else 'file')}{where}"
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 "")
if not regional:
if len(plain) > START_WITH_NAMES:
where = " They come first below." if first_below else ""
return [f"**Start with** the {_count(len(plain), 'file')} an emulator requires.{where}",
""]
return [named(plain, "**Start with**") + ".", ""]
lines = ["**Start with** the BIOS for the region of your games:", ""]
for regions in sorted(regional, key=lambda rs: (order.get(rs[0], len(order)), rs)):
lead = ", ".join(_region_word(r) for r in regions)
lines.append(f"- **{lead}**: {', '.join(_code(n) for n in regional[regions])}")
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".
shown = {_visible(n) for names in regional.values() for n in names}
plain = [n for n in group.plain if _visible(n) not in shown]
if plain:
lines.append(named(plain, "- **Some emulators also require**"))
out.append(("", listed(plain)))
if not out and group.optional:
out.append(("*", f"{_without_files(group.emulators)}. "
f"For full accuracy: {listed(group.optional)}."))
return out
def _start_lines(block: list[tuple[str, str]]) -> list[str]:
"""One system's start, as the lines its section opens on."""
lead, text = block[0]
if lead == "*":
return [f"**No file needed**{text}", ""]
if len(block) == 1 and lead == "":
named, _sep, where = text.partition(", first below")
below = " They come first below." if _sep else ""
return [f"**Start with**: {named}.{below}", ""]
lines = ["**Start with** the BIOS for the region of your games:", ""]
for lead, text in block:
lines.append(f"- **{lead or 'Some emulators also require'}**: {text}")
return [*lines, ""]
@@ -4491,15 +4847,20 @@ def _parts(cards: list[Card], budget: int, sizes: list[int] | None = None) -> li
def _console_heading(manufacturer: str, console: str) -> str:
"""A console's heading: its icon hidden from screen readers, then the
name with no space before it. The space read as "Section titled
“ PlayStation”" on five of seven Sony headings."""
anchor = f" {{ #{console_anchor(manufacturer, console)} }}"
icon = _icon_name(manufacturer, console)
if not _icon_available.get(icon):
return f"## {console}{anchor}"
image = markup.image("", _icon_url(icon), width="24", **{"aria-hidden": "true"})
return f"## {image}{console}{anchor}"
"""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)} }}"
def _romset_note(consoles: dict[str, list]) -> list[str]:
"""The line a page of MAME or FBNeo sets carries: an arcade player
looking for the version found the answer only under Reference."""
releases = _romset_releases()
if not any(str(f.get("name", "")) in releases for files in consoles.values() for f in files):
return []
return ["MAME and FBNeo sets must match the emulator's version. "
f"[Which version each core reads]({ROMSET_FAQ}).", ""]
_NEXT_FROM_SYSTEMS = [
@@ -4549,14 +4910,14 @@ def system_pages(
)
def whole_page(sections: dict[str, list[Card]]) -> str:
lines = [
f"# {manufacturer}", "", intro, "", key, "",
f"# {manufacturer}", "", intro, "", key, "", *_romset_note(consoles),
f"The files below by console, with every hash, are also published as {json_link}.",
"", *_icon_symbol(),
]
for console, cards in sections.items():
lines.append(_console_heading(manufacturer, console))
lines.append("")
lines.extend(_start_with(consoles[console], consumers))
lines.extend(_start_with(consoles[console], consumers, section=(manufacturer, console)))
lines.extend(_console_install_block(console, (installs or {}).get(console, [])))
lines.extend(_cards_markdown(cards))
lines.append("")
@@ -4572,26 +4933,40 @@ def system_pages(
with markup.using("mkdocs"):
measured = all_sections()
measured_text = whole_page(measured)
if len(measured_text) <= budget:
weights = {
console: sum(card_weight(card) for _variant, card, _sha1 in cards)
for console, cards in measured.items()
}
# An install box is a few lines of Markdown and about nine times that
# once highlighted: ten of them were 295 KB of the 583 KB Nintendo
# 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}
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:
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.
weights = {
console: sum(card_weight(card) for _variant, card, _sha1 in cards)
for console, cards in measured.items()
}
moved: set[str] = set()
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
moved.add(console)
# Then until what stays holds its estimate as it renders.
for console in sorted(rendered, key=lambda c: (-rendered[c], c)):
if sum(w for c, w in rendered.items() if c not in moved) <= SYSTEM_SUMMARY_HTML:
break
moved.add(console)
pages: dict[str, tuple[str, dict[str, list]]] = {}
summary = [
f"# {manufacturer}", "", intro, "", key, "",
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(),
@@ -4601,7 +4976,7 @@ def system_pages(
summary.append(_console_heading(manufacturer, console))
summary.append("")
if console not in moved:
summary.extend(_start_with(consoles[console], consumers))
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)))
@@ -4628,7 +5003,8 @@ def system_pages(
])
title = f"{manufacturer}: {console}"
count = _count(len(consoles[console]), "file")
summary.extend(_start_with(consoles[console], consumers, first_below=False))
summary.extend(_start_with(consoles[console], consumers, first_below=False,
section=(manufacturer, console)))
if len(stems) == 1:
summary.append(f"{count}, on [their own page]({stems[0]}.md).")
else:
@@ -4650,8 +5026,10 @@ def system_pages(
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}.", ""])
if index == 0:
lines.extend(_start_with(consoles[console], consumers, first_below=len(stems) == 1))
lines.extend(_start_with(consoles[console], consumers, first_below=len(stems) == 1,
section=(manufacturer, console)))
lines.extend([key, ""])
lines.extend(_romset_note({console: consoles[console]}))
lines.extend(_console_install_block(console, (installs or {}).get(console, [])))
lines.extend(["## Files", ""])
lines.extend(_cards_markdown(part))
+65 -14
View File
@@ -5,14 +5,16 @@ Each card is an SVG written by ``sitediagrams.og_card_svg`` from the
registry, rasterised by a headless Chromium through Playwright, then
committed under ``docs_assets/og/``. The site build copies the PNG files and
never runs this script, so it adds nothing to the build's dependencies.
Run it again when a section's wording, the platform count or the list of
platforms changes.
Run it again when a section's wording, the platform count, the list of
platforms or the consoles of a manufacturer change.
python scripts/make_og_cards.py
python scripts/make_og_cards.py --only platforms
python scripts/make_og_cards.py --only systems
A platform page shares a card with its own name (``og/platforms/<id>.png``),
the other pages their section's.
a manufacturer's systems page one with the manufacturer's name and its
consoles (``og/systems/<slug>.png``), the other pages their section's.
"""
from __future__ import annotations
@@ -25,27 +27,73 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
from common import list_registered_platforms, load_platform_config # noqa: E402
from sitediagrams import OG_SECTIONS, og_card_svg # noqa: E402
from sitediagrams import OG_SECTIONS, og_card_svg, wrap # noqa: E402
OUT = ROOT / "docs_assets" / "og"
LOGO = ROOT / ".github" / "assets" / "logo.png"
PLATFORM_TAGLINE = "Which BIOS files it needs, and where each one goes"
# The tagline's box on the card: og_card_svg wraps it at this width and
# size, and three lines fit above the address.
TAGLINE_WIDTH, TAGLINE_SIZE, TAGLINE_LINES = 1000, 34, 3
def platform_cards() -> dict[str, str]:
def platform_cards() -> dict[str, tuple[str, str]]:
"""Each platform page's card, by its path under og/, with its title."""
platforms = ROOT / "platforms"
return {
f"platforms/{name}": load_platform_config(name, str(platforms))["platform"]
f"platforms/{name}": (load_platform_config(name, str(platforms))["platform"],
PLATFORM_TAGLINE)
for name in list_registered_platforms(str(platforms), include_archived=True)
}
def card_svg(key: str, logo: bytes, titles: dict[str, str]) -> str:
def consoles_tagline(consoles: list[str], lead: str = "BIOS files for") -> str:
""""BIOS files for PlayStation, PlayStation 2 and 5 more": as many
names as the card's three lines hold, the rest counted."""
names = sorted(consoles, key=str.casefold)
for shown in range(len(names), 0, -1):
rest = len(names) - shown
listed = names[:shown]
if rest:
text = f"{lead} {', '.join(listed)} and {rest} more"
elif len(listed) > 1:
text = f"{lead} {', '.join(listed[:-1])} and {listed[-1]}"
else:
text = f"{lead} {listed[0]}"
if len(wrap(text, TAGLINE_WIDTH, TAGLINE_SIZE)) <= TAGLINE_LINES:
return text
return f"{lead} {len(names)} sections"
def system_cards() -> dict[str, tuple[str, str]]:
"""Each manufacturer page's card under og/systems/, titled with the
manufacturer and listing its consoles. Read from the same grouping the
system pages are written from."""
from common import GAME_DATA_TOPS, load_database
from generate_site import _group_by_manufacturer, _slug
def lead(manufacturer: str) -> str:
# Other holds one folder per emulator, a game publisher's page its
# games' data: neither is a list of consoles.
if manufacturer in GAME_DATA_TOPS:
return "Game data for"
return "Files kept for the emulators" if manufacturer == "Other" else "BIOS files for"
manufacturers = _group_by_manufacturer(load_database(str(ROOT / "database.json")))
return {
f"systems/{_slug(manufacturer)}": (
manufacturer, consoles_tagline(list(consoles), lead(manufacturer)))
for manufacturer, consoles in sorted(manufacturers.items())
}
def card_svg(key: str, logo: bytes, titles: dict[str, tuple[str, str]]) -> str:
if key in OG_SECTIONS:
return og_card_svg(key, logo)
# A platform card is a section card with the platform's name for title.
OG_SECTIONS[key] = (titles[key], PLATFORM_TAGLINE)
# A page card is a section card with the page's own title and tagline.
# The tagline is a format string there: braces in a name stay text.
title, tagline = titles[key]
OG_SECTIONS[key] = (title, tagline.replace("{", "{{").replace("}", "}}"))
try:
return og_card_svg(key, logo)
finally:
@@ -55,16 +103,19 @@ def card_svg(key: str, logo: bytes, titles: dict[str, str]) -> str:
def main() -> None:
parser = argparse.ArgumentParser(description="Render the sharing cards to PNG.",
allow_abbrev=False)
parser.add_argument("--only", choices=("sections", "platforms"),
help="render one kind of card (default: both)")
parser.add_argument("--only", choices=("sections", "platforms", "systems"),
help="render one kind of card (default: all)")
args = parser.parse_args()
try:
from playwright.sync_api import sync_playwright
except ImportError as exc:
raise SystemExit("Playwright with Chromium is needed: pip install playwright") from exc
titles = platform_cards()
keys = [*([] if args.only == "platforms" else OG_SECTIONS),
*([] if args.only == "sections" else titles)]
titles: dict[str, tuple[str, str]] = {}
if args.only in (None, "platforms"):
titles.update(platform_cards())
if args.only in (None, "systems"):
titles.update(system_cards())
keys = [*(OG_SECTIONS if args.only in (None, "sections") else []), *titles]
logo = LOGO.read_bytes()
with sync_playwright() as playwright:
browser = playwright.chromium.launch()