chore: merge round 5 system page fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-24 01:30:36 +02:00
commit 4db718bb5c
9 files changed
+818 -72

No files matched your search

+378 -48
View File
@@ -4642,14 +4642,92 @@ def _group_by_manufacturer(db: dict) -> dict[str, dict[str, list]]:
The database keeps one path per content, and a copy filed elsewhere was
on no page of its own: the Channel F BIOS sat under Arcade > FBNeo only,
and Fairchild had no system page. Every folder holding a copy lists it,
read from the full paths by_path_suffix records.
read from the full paths by_path_suffix records. The game engines'
files are grouped by engine (_engine_sections).
"""
files = db.get("files", {})
manufacturers: dict[str, dict[str, list]] = {}
for sha1, where in _copy_places(db).items():
for manufacturer, console in sorted({(m, c) for m, c, _ in where}):
manufacturers.setdefault(manufacturer, {}).setdefault(console, []).append(files[sha1])
return manufacturers
return _engine_sections(manufacturers)
def _engine_sections(manufacturers: dict[str, dict[str, list]]) -> dict[str, dict[str, list]]:
"""A game engine's files in one section headed with the engine's name.
The Systems index listed ".variants", "scummvm.zip" and "wildmidi.cfg"
as games, and ScummVM's files sat on three pages. A folder of variants
joins the section of its primary, a file kept at a top's root joins
the section of the one engine that reads it (else of the engine the
top is named after), the folder under Other named after a game-engine
top joins that top, and a folder named by a profile key is headed with
the engine's name (SECTION_LABELS), its address unchanged.
"""
from siterender import _PROGRAM_TOPS, SECTION_LABELS, record_card_move # noqa: PLC0415
profiles = {key: profile for key, profile in _profiles_on_disk().items()
if profile.get("type") not in ("alias", "test")}
readers: dict[str, set[str]] = {}
for key, profile in profiles.items():
for entry in profile.get("files") or []:
if isinstance(entry, dict):
for name in [entry.get("name"), *(entry.get("aliases") or [])]:
if name:
readers.setdefault(str(name).rsplit("/", 1)[-1], set()).add(key)
def engine(key: str) -> str:
return str(profiles[key].get("emulator") or key)
def profile_of(name: str) -> str | None:
return next((k for k in sorted(profiles)
if name.casefold() in (k.casefold(), engine(k).casefold())), None)
out: dict[str, dict[str, list]] = {}
for mfr, consoles in manufacturers.items():
if mfr not in GAME_DATA_TOPS:
out[mfr] = dict(consoles)
continue
own = profile_of(mfr)
regrouped: dict[str, list] = {}
for console, files in sorted(consoles.items(), key=lambda item: item[0].startswith(".")):
for f in files:
parts = str(f.get("path", "")).split("/")
target = console
if console.startswith(".") or (len(parts) == 3 and console == parts[-1]):
# A variant answers its primary's name: scummvm.zip.a93f1c4b.
base = parts[-1].rsplit(".", 1)[0] if console.startswith(".") else parts[-1]
keys = sorted(readers.get(base, ()))
key = keys[0] if len(keys) == 1 else own
found = next((c for c, held in regrouped.items()
if any(h.get("name") == base for h in held)), None)
target = found or (next((c for c in consoles if c == key), None)
or (engine(key) if key else base))
if target != console:
record_card_move(mfr, console, str(f.get("sha1", "")), mfr, target)
regrouped.setdefault(target, []).append(f)
out[mfr] = regrouped
# The folder under Other an engine top is named after: ScummVM's
# engine data under Other/ScummVM, its themes under ScummVM/.
others = out.get("Other", {})
for folder in sorted(others):
top = next((t for t in out if t in GAME_DATA_TOPS and t.casefold() == folder.casefold()),
None)
if top is None or top in _PROGRAM_TOPS:
continue
sections = out[top]
target = next((c for c in sections if c.casefold() == folder.casefold()), folder)
held = {str(f.get("sha1", "")) for f in sections.get(target, [])}
for f in others[folder]:
record_card_move("Other", folder, str(f.get("sha1", "")), top, target)
sections[target] = [*sections.get(target, []),
*(f for f in others.pop(folder) if str(f.get("sha1", "")) not in held)]
for mfr in GAME_DATA_TOPS:
for console in out.get(mfr, {}):
key = profiles.get(console) and console
if key and engine(key) != console:
SECTION_LABELS[(mfr, console)] = engine(key)
return {mfr: consoles for mfr, consoles in out.items() if consoles}
def _console_display(manufacturer: str, console: str) -> str:
@@ -4668,6 +4746,8 @@ def generate_systems_index(manufacturers: dict) -> str:
PlayStation had to know it is filed under Sony, and Other hid 91
sections. Manufacturer pages stay the containers.
"""
for old, new in SYSTEM_PAGES_MOVED.items():
sitestarlight.MOVED_PAGES.setdefault(old, new)
total_mfr = len(manufacturers)
total_consoles = sum(len(c) for c in manufacturers.values())
# A copy filed under two folders appears on both pages, counted once here.
@@ -4710,6 +4790,7 @@ def generate_systems_index(manufacturers: dict) -> str:
rows = sorted(
((_folder_emulator(console)[0] if group == "programs"
else _section_label(mfr, console) if mfr == "Arcade"
or (mfr, console) in SECTION_LABELS
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()),
@@ -5693,10 +5774,29 @@ def _console_cards(
if by_name:
return (place.get(str(f.get("sha1", "")), _NO_PRIORITY), *_by_name(f))
return _card_rank(f, consumers, place)
# Another revision of a MAME or FBNeo set is another release's set, its
# Romset line saying which: it sits open beside the set of its name.
# Twelve neogeo.zip were on five pages, the SNK ones closed under
# Variants.
romsets = _romset_releases()
beside = [f for f in files if "/.variants/" in f["path"] and f["name"] in romsets]
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"]]
variant_files = [f for f in files if "/.variants/" in f["path"] and f not in beside]
# Sets of one name side by side, the first ranked leading.
ranked = sorted(main_files, key=order)
by_set: dict[str, list[dict]] = {}
for f in [*ranked, *sorted(beside, key=order)]:
if f["name"] in romsets:
by_set.setdefault(f["name"], []).append(f)
ordered: list[dict] = []
for f in ranked:
if f["name"] not in romsets:
ordered.append(f)
elif by_set.get(f["name"]):
ordered.extend(by_set.pop(f["name"]))
ordered.extend(f for files in by_set.values() for f in files)
cards = [(False, _system_file_card(f, consumers, anchored, searchable), str(f.get("sha1", "")))
for f in sorted(main_files, key=order)]
for f in ordered]
cards += [(True, _system_file_card(v, consumers, anchored, searchable), str(v.get("sha1", "")))
for v in sorted(variant_files, key=order)]
return cards
@@ -5840,11 +5940,30 @@ class _StartGroup:
self.hle: set[str] = set()
# Files an emulator reads when present and runs the same without.
self.extra: list[str] = []
# By line (a region, "" for every region): visible name -> the
# emulators requiring that file for the line. Three Mega CD BIOS,
# one per region, share the name cd_bios.rom.
self.line_readers: dict[str, dict[str, set[str]]] = {}
# (line, visible name, emulator) -> the name the emulator reads.
self.read_as: dict[tuple[str, str, str], str] = {}
# (line, visible name) -> the file name its card shows.
self.card_names: dict[tuple[str, str], str] = {}
# (line, visible name) of the files most of whose readers read
# them beside another one they require.
self.extras: set[tuple[str, str]] = set()
def require(self, name: str, emulator: str) -> None:
self.written.setdefault(_visible(name), name)
self.requires.setdefault(_visible(name), set()).add(emulator)
def read_for(self, line: str, name: str, emulator: str, read: str, card: str) -> None:
"""Record that ``emulator`` requires ``name`` for ``line``, under
the name ``read``, its card titled ``card``."""
visible = _visible(name)
self.line_readers.setdefault(line, {}).setdefault(visible, set()).add(emulator)
self.read_as.setdefault((line, visible, emulator), read)
self.card_names.setdefault((line, visible), card)
def absorb(self, other: _StartGroup) -> None:
for region, names in other.by_region.items():
for name in names:
@@ -5859,10 +5978,38 @@ class _StartGroup:
for visible, sha1 in other.sha1s.items():
self.sha1s.setdefault(visible, sha1)
self.hle.update(other.hle)
for line, readers in other.line_readers.items():
for visible, emulators in readers.items():
self.line_readers.setdefault(line, {}).setdefault(visible, set()).update(emulators)
for key, value in other.read_as.items():
self.read_as.setdefault(key, value)
for key, value in other.card_names.items():
self.card_names.setdefault(key, value)
self.extras |= other.extras
def requirers(self) -> set[str]:
return set().union(*self.requires.values()) if self.requires else set()
def as_read(self, visible: str, emulators: set[str], line: str | None = None) -> str:
"""A file as an emulator reading it instead is told to fetch it:
by its card's name, with the name it reads when that differs.
The line read "ares reads neo-epo.sp1" above a card titled
neo-epo.bin."""
lines = [line] if line is not None else sorted(self.line_readers)
for key in lines:
card = self.card_names.get((key, visible))
if card is None:
continue
reads = {self.read_as[(key, visible, e)] for e in emulators
if (key, visible, e) in self.read_as}
written = self.written.get(visible, _code(card))
# A linked name (a file under Arcade) keeps its link.
shown = written if _visible(written) == card else _code(card)
if len(reads) == 1 and next(iter(reads)) != card:
return f"{shown} (as {_code(next(iter(reads)))})"
return shown
return self.written.get(visible, _code(visible))
def _start_groups(
files: list[dict], consumers: FileConsumers, section: tuple[str, str] | None
@@ -5938,12 +6085,36 @@ def _start_groups(
return _code(archive), ""
return f"[{_code(archive)}]({markup.site_href(f'files/?sha1={found[0]}')})", ""
for f in ranked:
def reading(f: dict) -> Iterator[tuple[str, dict, str | None, str, str]]:
"""Each read of a file: the emulator, its entry, the system, the
name the line writes and the sha1 of the card it names."""
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"]))
yield emulator, entry, system, name, named
# A file as many of whose readers require another file and read this
# one only when present is an extra, not a start file: NAOMI opened on
# hod2bios.zip, which 13 of the 19 emulators listing it read beside
# naomi.zip for the House of the Dead 2 alone.
requiring: dict[str, set[str]] = {}
optional: dict[str, set[str]] = {}
for f in ranked:
for emulator, entry, _system, name, named in reading(f):
if entry.get("hle_fallback"):
continue
side = requiring if entry.get("required", True) else optional
side.setdefault(named or _visible(name), set()).add(emulator)
needs_other = {emulator: {key for key, emulators in requiring.items() if emulator in emulators}
for emulators in requiring.values() for emulator in emulators}
weak = {key for key, emulators in requiring.items()
if len(emulators) <= len({e for e in optional.get(key, ())
if needs_other.get(e, set()) - {key}})}
for f in ranked:
for emulator, entry, system, name, named in reading(f):
try:
key = system_name(system) if system else None
except KeyError:
@@ -5961,9 +6132,14 @@ def _start_groups(
_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)
read = str(entry.get("archive") or entry.get("name") or _visible(name))
card = str(entry["archive"]) if entry.get("archive") else str(f["name"])
regions = sorted({str(r) for r in entry.get("region") or []})
for region in regions or [""]:
_add_name(group.by_region.setdefault(region, []) if region else group.plain, name)
group.read_for(region, name, emulator, read, card)
if (named or _visible(name)) in weak:
group.extras.add((region, _visible(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.
@@ -6057,6 +6233,11 @@ def _group_lines(
if lead == "*":
lines.append(f"- **{key}**: no file needed{text}")
continue
if lead == "+":
# What the others read instead ends the machine's line.
end = "" if lines[-1].endswith(".") else "."
lines[-1] = f"{lines[-1]}{end} {text}"
continue
lines.append(f"- **{key}, {lead}**: {text}" if lead else f"- **{key}**: {text}")
return [*lines, ""]
@@ -6084,11 +6265,13 @@ def _lead_file(groups: dict[str | None, _StartGroup]) -> list[str]:
emulators: set[str] = set()
requires: dict[str, set[str]] = {}
written: dict[str, str] = {}
holder: dict[str, _StartGroup] = {}
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])
holder.setdefault(visible, group)
if not requires:
return []
top = max(requires, key=lambda v: len(requires[v]))
@@ -6102,10 +6285,11 @@ def _lead_file(groups: dict[str | None, _StartGroup]) -> list[str]:
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.
# files they require, each by its card's name.
others: dict[tuple[str, ...], set[str]] = {}
for emulator in emulators - readers:
theirs = tuple(written[v] for v in requires if emulator in requires[v])
theirs = tuple(holder[v].as_read(v, {emulator}) for v in requires
if emulator in requires[v])
if theirs:
others.setdefault(theirs, set()).add(emulator)
sentences = []
@@ -6146,9 +6330,48 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]
if requirers and 2 * len(requirers) <= 2 * len(running - hle) + len(hle):
free = _display_names(running)
needing = _display_names(requirers)
names = list(group.written.values())
verb = "requires" if len(needing) == 1 else "require"
if all(len(readers) == len(requirers) for readers in group.requires.values()):
return [("*", f" with {_names_counted(free)}. Only {_names_counted(needing)} {verb} "
f"{listed(names, False)}.")]
# Each requires some of the files: "bsnes, FinalBurn Neo and 3
# more require 22 files" read as 22 files for a bsnes user.
these = f"these {len(names)} files" if len(names) > START_WITH_NAMES else "these files"
return [("*", f" with {_names_counted(free)}. Only {_names_counted(needing)} {verb} "
f"{listed(list(group.written.values()), False)}.")]
f"some of {these}: {listed(names, False)}.")]
def instead_of(line: str, names: list[str], answered: set[str]) -> str:
"""What the emulators no other name answers read instead, one
sentence per set of files: "FinalBurn Neo reads fdsbios.zip instead"."""
readers = group.line_readers.get(line, {})
others: dict[tuple[str, ...], set[str]] = {}
for emulator in sorted(set().union(*(readers.get(_visible(n), set()) for n in names))
- answered):
theirs = tuple(group.as_read(_visible(n), {emulator}, line) for n in names
if emulator in readers.get(_visible(n), set()))
others.setdefault(theirs, set()).add(emulator)
sentences = []
for files, who in sorted(others.items(), key=lambda item: (-len(item[1]), item[0])):
shown = _display_names(who)
verb = "reads" if len(shown) == 1 else "read"
sentences.append(f"{_names_counted(shown)} {verb} {_said_as_list(list(files))} instead.")
return " ".join(sentences)
def split(line: str, names: list[str]) -> tuple[list[str], str, set[str]]:
"""A line's names as the files to start with, the sentence naming
what other emulators read instead, and every emulator the line
answers. Names no emulator requires beside another are other names
for one slot: "fdsbios.zip, disksys.rom" read as two files to get."""
readers = group.line_readers.get(line, {})
sets = {n: readers.get(_visible(n), set()) for n in names}
lead = max(names, key=lambda n: len(sets[n]))
main = [n for n in names if n == lead or sets[n] <= sets[lead]]
rest = [n for n in names if n not in main]
# A file the lead's readers require beside it is a start file too,
# unless most of its readers take it as an extra.
main = [n for n in main if n == lead or (line, _visible(n)) not in group.extras]
return main, instead_of(line, rest, set(sets[lead])), set().union(*sets.values())
order = _region_order()
# A territory its region answers the same way adds nothing: Brazil
@@ -6156,22 +6379,39 @@ def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]
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]] = {}
regional: dict[tuple[str, ...], str] = {}
answered: set[str] = set()
shown: set[str] = set()
for region in sorted(by_region, key=lambda r: (order.get(r, len(order)), r)):
names = by_region[region]
same = next((rs for rs, held in regional.items() if held == names), None)
main, sentence, readers = split(region, by_region[region])
answered |= readers
shown |= {_visible(n) for n in by_region[region]}
text = f"{listed(main)}. {sentence}" if sentence else listed(main)
same = next((rs for rs, held in regional.items() if held == text), None)
if same is None:
regional[(region,)] = names
regional[(region,)] = text
else:
regional[(*same, region)] = regional.pop(same)
out = [(", ".join(_region_word(r) for r in regions), listed(regional[regions]))
out = [(", ".join(_region_word(r) for r in regions), 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}
# A name a region line gives already is no "also", and one only
# emulators no region line answers reads is an "instead": armsx1 reads
# scph1001.bin where the others pick a BIOS by region.
plain = [n for n in group.plain if _visible(n) not in shown]
if plain:
out.append(("", listed(plain)))
if plain and out:
readers = group.line_readers.get("", {})
also = [n for n in plain if readers.get(_visible(n), set()) & answered]
if also:
out.append(("", listed(also)))
sentence = instead_of("", [n for n in plain if n not in also], answered)
if sentence:
out.append(("+", sentence))
elif plain:
main, sentence, _readers = split("", plain)
out.append(("", listed(main)))
if sentence:
out.append(("+", sentence))
if not out and group.optional:
out.append(("*", f"{_without_files(group.emulators)}. "
f"For full accuracy: {listed(group.optional)}."))
@@ -6191,14 +6431,19 @@ def _start_lines(block: list[tuple[str, str]], console: str = "") -> list[str]:
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")
# What the emulators no line answers read instead, after the lines.
instead = " ".join(text for lead, text in block if lead == "+")
block = [(lead, text) for lead, text in block if lead != "+"]
if len(block) == 1 and block[0][0] == "":
named, _sep, _where = block[0][1].partition(", first below")
below = " They come first below." if _sep else ""
return [f"**Start with**: {named}.{below}", ""]
return [" ".join(filter(None, [f"**Start with**: {named}.{below}", instead])), ""]
bios = f"the {console} BIOS" if console else "the BIOS"
lines = [f"**Start with** {bios} for the region of your games:", ""]
for lead, text in block:
lines.append(f"- **{lead or 'Some emulators also require'}**: {text}")
if instead:
lines += ["", instead]
return [*lines, ""]
@@ -6262,6 +6507,23 @@ _BOARD_CONSOLES: dict[str, str] = {}
# its maker, which said nothing about what it holds.
OTHER_ARCADE = ("Arcade", "Arcade")
OTHER_ARCADE_LABEL = "Other arcade files"
# (manufacturer, console) -> the boards whose sets left that folder's
# section for the boards' own pages. The section names them.
_BOARDS_FROM: dict[tuple[str, str], set[str]] = {}
# System pages published under an address no page holds since, and where
# their files went: the Atom and the MZ were boards, ScummVM's and SDLPAL's
# files were on two pages, the Dreamcast lost its NAOMI sets. An address
# redirects only while no page takes it back (sitestarlight.redirects).
SYSTEM_PAGES_MOVED = {
"systems/arcade-acorn-atom.md": "systems/acorn.md",
"systems/arcade-sharp-mz-2000.md": "systems/sharp.md",
"systems/arcade-sharp-mz-2500.md": "systems/sharp.md",
"systems/arcade-miuchiz.md": "systems/arcade.md",
"systems/arcade-yamaha-ym2608.md": "systems/arcade.md",
"systems/other-scummvm.md": "systems/scummvm.md",
"systems/other-sdlpal.md": "systems/sdlpal.md",
"systems/sega-dreamcast.md": "systems/sega.md",
}
def arcade_board_sections(
@@ -6274,18 +6536,23 @@ def arcade_board_sections(
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
board (Namco System 246) joins it, whatever maker's folder holds the
copy. A set a reviewed machine owns (siterender.archive_machine) goes
to that machine's section. Files neither 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,
archive_machine,
record_board_section,
record_card_move,
system_section,
)
_BOARD_CONSOLES.clear()
_BOARDS_FROM.clear()
arcade = manufacturers.get("Arcade")
if not arcade:
return manufacturers, installs
@@ -6294,23 +6561,66 @@ def arcade_board_sections(
boards: dict[str, list] = {}
folders: dict[str, list] = {}
seen: dict[str, set[str]] = {}
# The sets a reviewed machine owns go to its section: neogeo.zip sat on
# five Arcade pages, the Atom's and the MZ-2000's sets on board pages.
machines: dict[tuple[str, str], list] = {}
def file_on_board(board: str, f: dict) -> None:
# 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()):
return
seen[board].add(str(f.get("sha1", "")))
boards.setdefault(board, []).append(f)
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:
sha1 = str(f.get("sha1", ""))
board = joined or arcade_board(sha1)
machine = None if board else archive_machine(sha1)
if board is not None:
file_on_board(board, f)
elif machine is not None:
machines.setdefault(machine, []).append(f)
record_card_move("Arcade", console, sha1, *machine)
else:
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)
# A board's set filed under a machine's folder is on the board's page
# too, whatever the folder: naomi.zip under the Dreamcast was the copy
# five setups list, and the NAOMI page did not name it.
makers: dict[str, dict[str, list]] = {}
for mfr, consoles in manufacturers.items():
if mfr == "Arcade":
continue
for console, files in consoles.items():
kept = []
for f in files:
sha1 = str(f.get("sha1", ""))
board = arcade_board(sha1)
machine = None if board else archive_machine(sha1)
if board is not None:
file_on_board(board, f)
_BOARDS_FROM.setdefault((mfr, console), set()).add(board)
elif machine is not None and machine != (mfr, console):
# The Atom's own set sat with the BBC Micro.
machines.setdefault(machine, []).append(f)
record_card_move(mfr, console, sha1, *machine)
else:
kept.append(f)
if len(kept) != len(files):
makers.setdefault(mfr, dict(consoles))[console] = kept
for (mfr, console), files in sorted(machines.items()):
consoles = makers.setdefault(mfr, dict(manufacturers.get(mfr, {})))
held = {str(f.get("sha1", "")) for f in consoles.get(console, [])}
consoles[console] = [*consoles.get(console, []),
*(f for f in files if str(f.get("sha1", "")) not in held)]
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()}}
makers = {mfr: {console: files for console, files in consoles.items() if files}
for mfr, consoles in makers.items()}
moved: dict[str, dict[str, list[tuple[str, str]]]] = {}
for slug, consoles in installs.items():
@@ -6322,7 +6632,21 @@ def arcade_board_sections(
else:
slug_to, console_to = slug, console
moved.setdefault(slug_to, {}).setdefault(console_to, []).append((platform, sys_id))
return {**manufacturers, "Arcade": regrouped}, moved
return {**manufacturers, **makers, "Arcade": regrouped}, moved
def _boards_line(manufacturer: str, console: str) -> list[str]:
"""The line of a section whose board sets went to the boards' pages:
the Dreamcast folder holds NAOMI's and Atomiswave's."""
from siterender import arcade_boards # noqa: PLC0415
boards = _BOARDS_FROM.get((manufacturer, console))
if not boards:
return []
names = arcade_boards()
links = [f"[{names[board]}]({console_href('arcade', names[board]).removeprefix('systems/')})"
for board in sorted(boards, key=lambda b: names[b].casefold())]
return [f"**Arcade board BIOS** on the boards' own pages: {', '.join(links)}.", ""]
def _section_label(manufacturer: str, console: str) -> str:
@@ -6433,7 +6757,8 @@ def system_pages(
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))
return [*_start_with(consoles[console], consumers, first_below, (manufacturer, console)),
*_boards_line(manufacturer, console)]
starts = {console: [] if boards and console not in boards
else _start_order(consoles[console], consumers, (manufacturer, console))
@@ -6592,9 +6917,12 @@ def system_pages(
])
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))
# nothing a search result could use. A folder under Other goes by
# its emulator's name: "mame files" named the folder.
named = _folder_emulator(console)[0] if manufacturer == "Other" else None
titles = ([f"{manufacturer}: {named or label}"] if len(stems) == 1 else [
"{} files {} to {}".format(named or _part_name(manufacturer, console),
*_part_ends(part, lead))
for part in parts])
count = _count(len(consoles[console]), "file")
summary.extend(start_of(console, False))
@@ -6617,25 +6945,23 @@ def system_pages(
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(),
# The crumbs end on the maker's page: no line says it again.
f"# {titles[index]}", "", *_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, 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)} }}", ""])
# A board's page is its one section: its title names it, and a
# heading of the same name said it a third time. Its cards come
# under "Files", as every page of one console's.
if index == 0:
lines.extend(start_of(console, len(stems) == 1))
lines.extend(legend)
lines.extend(_romset_note({console: consoles[console]}))
lines.extend(install_block(console))
if not board:
lines.extend(["## Files", ""])
lines.extend(["## Files", ""])
lines.extend(_cards_markdown(part))
lines.append("")
lines.extend(_next_block([f"[{manufacturer}]({slug}.md), every section"]
@@ -7487,8 +7813,12 @@ def _console_install_block(console: str, declared: list[tuple[str, str]]) -> lis
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}", [
# The name last: "Install the Atari System 1 files" read as a count. A
# label naming files already reads "Install these files": "Install
# the files of Other arcade files".
summary = ("Install these files" if console.casefold().endswith(" files")
else f"Install the files of {console}")
return search_excluded(markup.details("tip", summary, [
"Each platform names the system its own way.",
"",
"Linux, macOS, Steam Deck:",
+87 -10
View File
@@ -193,28 +193,44 @@ def console_anchor(manufacturer: str, console: str) -> str:
def console_href(slug: str, console: str, prefix: str = "") -> str:
"""Where a console's files are: its own page, or its section of the
manufacturer's page (also where a console cut into parts lists them).
An arcade board's section heads the page of its own name."""
An arcade board's section is the page of its own name, no heading
repeating its title."""
if slug == "arcade" and _board_sections:
board = _board_ids_by_name().get(console.casefold())
if board in _board_sections:
anchor = board_slug(board)
return f"{prefix}systems/arcade-{urllib.parse.quote(anchor)}.md#{anchor}"
return f"{prefix}systems/arcade-{urllib.parse.quote(board_slug(board))}.md"
stems = _console_pages.get((slug, console))
if stems and len(stems) == 1:
return f"{prefix}systems/{urllib.parse.quote(stems[0])}.md"
return f"{prefix}systems/{urllib.parse.quote(slug)}.md#{console_anchor(slug, console)}"
# (manufacturer slug, folder, sha1) -> the (manufacturer slug, console) a
# copy's card went to when the system pages file it outside its folder:
# ScummVM's engine data under Other/ScummVM, neogeo.zip under Arcade.
_card_moves: dict[tuple[str, str, str], tuple[str, str]] = {}
def record_card_move(manufacturer: str, folder: str, sha1: str,
to_manufacturer: str, to_console: str) -> None:
"""Mark that the card of the copy under bios/<manufacturer>/<folder>/
sits in the section (to_manufacturer, to_console)."""
_card_moves[(_manufacturer_slug(manufacturer), folder, str(sha1))] = (
_manufacturer_slug(to_manufacturer), to_console)
def card_page(path: str, sha1: str) -> str:
"""The systems/ page stem holding the card of the copy at ``path``
(bios/<Manufacturer>/<Console>/...)."""
(bios/<Manufacturer>/<Console>/...). A board's set is on the board's
page whatever folder holds the copy."""
parts = path.split("/")
slug = parts[1].lower().replace(" ", "-")
console = parts[2] if len(parts) > 2 else ""
if slug == "arcade" and _board_sections:
if _board_sections:
board = arcade_board(sha1)
if board in _board_sections:
return f"arcade-{board_slug(board)}"
slug, console = _card_moves.get((slug, console, str(sha1)), (slug, console))
return _card_pages.get((slug, console, sha1), slug)
@@ -246,6 +262,10 @@ def system_section(sys_id: str) -> tuple[str, str] | None:
A heading under Arcade counts when it reads the name exactly (Namco
System 246). One under Other never does, its headings name programs."""
if sys_id in BOARD_PARENTS and _board_sections:
board = _board_of_id(BOARD_PARENTS[sys_id])
if board in _board_sections:
return "arcade", system_name(board)
section = _section_without_boards(sys_id)
if (not _board_sections or sys_id in SYSTEM_PARENTS
or (section is not None and section[0] != "arcade")):
@@ -301,6 +321,9 @@ _arcade_source: list[tuple[dict, dict] | None] = [None]
_archive_boards: dict[str, str] = {}
# Casefolded board name -> the board id that stands for it.
_board_names: dict[str, str] = {}
# sha1 -> the (manufacturer, console) sections of the machines whose own
# set the archive is: neogeo.zip for the Neo Geo, mz2000.zip for the MZ.
_archive_machines: dict[str, set[tuple[str, str]]] = {}
_boards_built = [False]
@@ -392,6 +415,7 @@ def _build_boards() -> None:
# sha1 -> board name -> the profiles naming it. None: a machine's own.
votes: dict[str, dict[str, set[str]] | None] = {}
_archive_machines.clear()
ids_by_name: dict[str, set[str]] = {}
for key in sorted(profiles):
profile = profiles[key]
@@ -426,8 +450,18 @@ def _build_boards() -> None:
for owner in owners:
role = _board_role(owner)
if role == _MACHINE:
# The profile's only system stands for an entry that names
# none: MAME's printer and expansion sets in a Dragon
# profile. Only an entry naming the machine moves its set,
# and only when every machine it names has that section:
# epson_fx80.zip, which sixteen machines load, has none.
declared = entry.get("system") or entry.get("systems")
places = {_machine_section(o) for o in owners
if _board_role(o) == _MACHINE} if declared else {None}
section = next(iter(places)) if len(places) == 1 else None
for sha1 in archives:
votes[sha1] = None
_archive_machines.setdefault(sha1, set()).add(section)
continue
if role is None:
continue
@@ -458,6 +492,27 @@ def _build_boards() -> None:
_boards_built[0] = True
def _machine_section(sys_id: str) -> tuple[str, str] | None:
"""The (manufacturer, console) section SYSTEM_PARENTS files a machine
id under, outside Arcade. Only a reviewed parent moves an archive out
of the folder holding it: an id with a section of its own keeps its
archives where the collection files them."""
parent = SYSTEM_PARENTS.get(sys_id)
if parent is None or parent[0] == "Arcade" or parent not in _section_pairs:
return None
return parent
def archive_machine(sha1: str) -> tuple[str, str] | None:
"""The (manufacturer, console) section of the machine an archive is
the set of, when a profile declares it for one machine only: the Neo
Geo's neogeo.zip, the MZ-2000's mz2000.zip. Read from the same
``category: bios_zip`` entries as arcade_board(), never the folder."""
_build_boards()
places = _archive_machines.get(str(sha1), set())
return next(iter(places)) if len(places) == 1 else None
def _board_ids_by_name() -> dict[str, str]:
_build_boards()
return _board_names
@@ -674,6 +729,12 @@ def set_console_sections(
{(mfr, console) for mfr, consoles in sections.items() for console in consoles}
if isinstance(sections, dict) else set(sections)
)
# A reviewed parent under a maker the pages carry is a section even with
# no folder of its own: the Atom's sets sit under bios/Arcade, and the
# system pages file them under Acorn (generate_site.arcade_board_sections).
makers = {mfr for mfr, _console in pairs}
pairs |= {parent for parent in SYSTEM_PARENTS.values()
if parent[0] in makers and parent[0] not in _PROGRAM_TOPS}
from sitestarlight import _unique, toc_slug
_console_sections.clear()
@@ -759,7 +820,7 @@ def _heading_name(mfr: str, console: str) -> str:
from common import GAME_DATA_TOPS
if mfr in GAME_DATA_TOPS:
return console
return SECTION_LABELS.get((mfr, console), console)
if console.casefold().startswith(mfr.split()[0].casefold()):
return console
return f"{mfr} {console}"
@@ -816,7 +877,6 @@ SYSTEM_NAMES: dict[str, str] = {
"atari-tt": "Atari TT",
"atari-zn1": "Atari ZN-1",
"atlus-zn1": "Atlus ZN-1",
"atom": "Acorn Atom",
"bandai-playdia": "Bandai Playdia",
"bandai-wswan": "Bandai WonderSwan",
"bandai-wswanc": "Bandai WonderSwan Color",
@@ -914,7 +974,6 @@ SYSTEM_NAMES: dict[str, str] = {
"gameplan-mpu1": "Game Plan MPU-1",
"gamepock": "Epoch Game Pocket Computer",
"gametank": "GameTank console",
"gba": "Nintendo Game Boy Advance",
"gearbox": "Half-Life: Opposing Force",
"ghostship": "Super Mario 64",
"glulx": "Glulx",
@@ -1281,6 +1340,12 @@ SYSTEM_PARENTS: dict[str, tuple[str, str]] = {
"philips-vg5k": ("Philips", "VG5000"),
"vg5k": ("Philips", "VG5000"),
"videobrain": ("VideoBrain", "Family Computer"),
# Computers whose sets sit under bios/Arcade with MAME's: the Atom has
# no folder of its own, its section holds those sets. The MZ-2000 and
# MZ-2500 sets join the MZ ones.
"atom": ("Acorn", "Atom"),
"mz2000": ("Sharp", "MZ"),
"mz2500": ("Sharp", "MZ"),
# Files kept at the maker's root, so each file is a heading of its own
# on a page that is the machine's: the first one leads there.
"galaksija": ("Galaksija", "Galaksija"),
@@ -1307,7 +1372,7 @@ SYSTEM_PARENTS: dict[str, tuple[str, str]] = {
"bbc-master-et": ("Other", "beebem"),
"cave-story": ("Other", "NXEngine"),
"dos": ("DOS", "DOSBox"),
"scummvm": ("Other", "ScummVM"),
"scummvm": ("ScummVM", "ScummVM"),
"sinclair-zx-spectrum-next": ("Other", "cspect"),
"solarus": ("Game Engines", "Solarus"),
"sunplus-spmp8000": ("Other", "spmp8000emu"),
@@ -1317,8 +1382,20 @@ SYSTEM_PARENTS: dict[str, tuple[str, str]] = {
"xrick": ("Other", "xrick"),
"zc210": ("ZC", "ZC210"),
# The collection's general arcade section, where naomi.zip, neogeo.zip
# and the other board sets sit.
# and the other board sets sit. A sound chip's set and a toy's stay in
# the Arcade folders holding them: neither is an arcade board.
"arcade": ("Arcade", "Arcade"),
"miuchiz": ("Arcade", "MAME"),
"ym2608": ("Arcade", "Arcade"),
}
# A system id that names a family of arcade boards -> the board its link
# leads to. RetroArch's sega-dreamcast-arcade lists the NAOMI, NAOMI 2 and
# Atomiswave BIOS: the NAOMI page comes first, the Dreamcast section links
# the others.
BOARD_PARENTS: dict[str, str] = {
"sega-dreamcast-arcade": "sega-naomi",
}
+2 -1
View File
@@ -361,7 +361,8 @@ class AVariantShowsWhoPinsIt(unittest.TestCase):
platforms={"c" * 40: {"batocera", "retrobat"}}, emulators={"a" * 40: {"mame"}})
page = generate_site.generate_system_page(
"Entex", {"Adventure Vision": [primary, variant]}, consumers)
block = page[page.index("<summary>Variants (1)</summary>"):]
# Another revision of a set sits open beside it (round 5, 6.3).
block = page[page.index('id="sha1-' + "c" * 12 + '"'):]
self.assertIn("<dt>Collection path</dt><dd><code>bios/Entex/Adventure Vision/"
".variants/advision.zip.1c086064</code>", block)
self.assertIn('<a href="../platforms/batocera.md">Batocera</a>', block)
+4
View File
@@ -114,7 +114,11 @@ class TheConsoleBiosComesFirst(unittest.TestCase):
def test_a_long_list_names_the_first_and_counts_the_rest(self):
files = [_file(f"rom{i}.bin", "abcdefgh"[i]) for i in range(8)]
# One emulator requires all eight: files each required by another
# emulator are other names for one slot (round 5, 6.2).
consumers = _consumers({f["sha1"]: [{"required": True}] for f in files})
consumers.emulator_files = {"emu": [{"required": True}] * len(files)}
consumers.emulator_entries = {f["sha1"]: {"emu": i} for i, f in enumerate(files)}
self.assertEqual(gs._start_with(files, consumers)[0],
"**Start with**: `rom0.bin`, `rom1.bin`, `rom2.bin` and 5 more files. "
"They come first below.")
+27 -1
View File
@@ -84,10 +84,36 @@ class MetaFields(unittest.TestCase):
match = re.search(r'data-pagefind-meta="aliases:([^"]*)"', text)
self.assertIsNotNone(match, "no aliases field on the PlayStation page")
self.assertIn("psx", unescape(match[1]).split())
dreamcast = (DOCS / "systems" / "sega-dreamcast.md").read_text(encoding="utf-8")
# The Dreamcast section's page: its own, or Sega's when it fits there.
own = DOCS / "systems" / "sega-dreamcast.md"
dreamcast = (own if own.is_file() else DOCS / "systems" / "sega.md").read_text(
encoding="utf-8")
self.assertRegex(dreamcast, r'data-pagefind-meta="aliases:[^"]*\bdc\b')
class SystemPartsSitUnderTheirMaker(unittest.TestCase):
"""The crumbs say where a system part sits, read from its parent: field.
The "Part of" line under them said it again (round 5, 6.9)."""
def test_every_part_names_its_maker_as_parent(self) -> None:
pages = sorted((DOCS / "systems").glob("*.md"))
if not pages:
self.skipTest("site pages not generated")
makers = {page.stem for page in pages}
orphans = []
for page in pages:
text = page.read_text(encoding="utf-8")
front = text.split("\n---\n", 1)[0]
parent = re.search(r"^parent:\n label: .+\n link: /retrobios/systems/([a-z0-9-]+)/$",
front, re.M)
if parent is None:
continue
if parent[1] not in makers:
orphans.append(page.name)
self.assertNotIn("\nPart of [", text, page.name)
self.assertEqual(orphans, [])
class MovedPlatformPages(unittest.TestCase):
def test_a_setups_list_for_one_console_is_a_reference(self) -> None:
source = Path(generate_site.__file__).read_text(encoding="utf-8")
+12 -7
View File
@@ -42,7 +42,7 @@ UNLINKED: dict[str, str] = {
"namco-nb1": "Arcade > FBNeo",
"omicron": "Other > omicron",
"rpgmaker": "RPG Maker > easyrpg",
"sdlpal": "Other > sdlpal",
"sdlpal": "the SDLPAL section under its own top, named after the program",
"sega-system16b": "Arcade > FBNeo, and isgsm.zip is also the ISG Selection Master's",
"smbx": "Game Engines > TheXTech",
"thomson-mo6": "Other > Clock Signal",
@@ -50,7 +50,6 @@ UNLINKED: dict[str, str] = {
# Files filed with another machine: a link would name the wrong one.
"commodore-c64-supercpu": "VICE's tree under the C128",
"emerson-arcadia-2001": "ar_bios.zip is filed with the Saturn",
"sega-dreamcast-arcade": "NAOMI, NAOMI 2 and Atomiswave sets, three boards",
"spectravideo": "the SVI ROMs are filed with the MSX",
"taito-type-x": "mame2010 files taitogn.zip, the G-NET BIOS, under this id",
"vvvvvv": "SDL's controller database",
@@ -105,7 +104,7 @@ class SystemLinks(unittest.TestCase):
load_platform_config,
resolve_local_file,
)
from generate_site import _copy_places, _group_by_manufacturer
from generate_site import _group_by_manufacturer, arcade_board_sections
with open("database.json", encoding="utf-8") as handle:
db = json.load(handle)
@@ -117,8 +116,9 @@ class SystemLinks(unittest.TestCase):
# (round 4, 6.4) and record it. The built pages are checked by
# the round 4 gate.
siterender.reset_console_pages()
for board in siterender.arcade_boards():
siterender.record_board_section(board)
# The sections as the system pages hold them: each board, the
# sets a reviewed machine owns under that machine.
cls.sections, _installs = arcade_board_sections(cls.manufacturers, {})
platforms = [
load_platform_config(name, "platforms")
for name in list_registered_platforms("platforms", include_archived=True)
@@ -141,7 +141,12 @@ class SystemLinks(unittest.TestCase):
for owner in owners:
entries.setdefault(owner, []).append(fe)
places = _copy_places(db)
# Where each content has a card.
places: dict[str, set[tuple[str, str, str]]] = {}
for mfr, consoles in cls.sections.items():
for console, files in consoles.items():
for f in files:
places.setdefault(f["sha1"], set()).add((mfr, console, f["name"]))
by_path = {entry.get("path"): sha1 for sha1, entry in db["files"].items()}
# Id -> the (manufacturer, console) sections holding its files.
cls.held: dict[str, set[tuple[str, str]]] = {}
@@ -210,7 +215,7 @@ class SystemLinks(unittest.TestCase):
def test_parents_are_sections(self) -> None:
sections = {
(mfr, console)
for mfr, consoles in self.manufacturers.items() for console in consoles
for mfr, consoles in self.sections.items() for console in consoles
}
self.assertEqual(
sorted(k for k, v in siterender.SYSTEM_PARENTS.items() if v not in sections), [])
+4 -3
View File
@@ -59,7 +59,6 @@ STAYS_PLAIN = {
"fpinball": "scripten.exe, filed with Windows",
"pv1000": "only MAME's software list pv1000.xml",
"vc4000": "only MAME's software list vc4000.xml",
"sega-dreamcast-arcade": "NAOMI, NAOMI 2 and Atomiswave sets, three boards",
"spectravideo": "the SVI ROMs are filed with the MSX",
"vgmplay": "sound chip sets, no board",
"vvvvvv": "SDL's controller database",
@@ -139,9 +138,11 @@ class Boards(unittest.TestCase):
slug = siterender.board_slug(board)
self.assertTrue(slug)
self.assertEqual(slug, toc_slug(name))
# The board's page is its section: no heading repeats its
# title (round 5, 6.8).
self.assertEqual(
siterender.console_href(*siterender.system_section(board)),
f"systems/arcade-{slug}.md#{slug}")
f"systems/arcade-{slug}.md")
def test_aliases_share_a_board(self) -> None:
"""Every id of one board's name leads to one section."""
@@ -171,7 +172,7 @@ class Boards(unittest.TestCase):
def test_parents_named_by_the_plan(self) -> None:
expect = {
"dos": ("dos", "DOSBox"),
"scummvm": ("other", "ScummVM"),
"scummvm": ("scummvm", "ScummVM"),
"tyrian": ("other", "opentyrian"),
"bbc-master-128": ("other", "beebem"),
}
+7 -2
View File
@@ -161,10 +161,12 @@ class EachArcadeBoardHasASection(unittest.TestCase):
"""6.4: the Arcade page was cut by storage folder."""
def test_each_board_heads_its_page(self) -> None:
# The page's title names the board, no h2 says it again (round 5, 6.8).
for name in BOARDS:
slug = toc_slug(name)
html = _page(DIST / "systems" / f"arcade-{slug}" / "index.html")
self.assertRegex(html, rf'<h2[^>]*id="{re.escape(slug)}"', name)
self.assertRegex(html, rf"<h1[^>]*>Arcade: {re.escape(name)}</h1>", name)
self.assertNotRegex(html, rf"<h2[^>]*>{re.escape(name)}<", name)
def test_every_board_on_an_emulator_systems_row_is_a_link(self) -> None:
if not (DIST / "emulators").is_dir():
@@ -223,7 +225,10 @@ class PartPagesKnowWhereTheyAre(unittest.TestCase):
orphans, numbered = [], []
for page in sorted((CONTENT / "systems").glob("*.md*")):
text = page.read_text(encoding="utf-8")
if "Part of " in text and "\nparent:" not in text.split("\n---\n", 1)[0]:
# A part is titled "<maker>: <console>" or "<name> files a to b".
title = re.search(r"^title: (.*)$", text, re.M)
part = title and (": " in title[1] or re.search(r" files .+ to ", title[1]))
if part and "\nparent:" not in text.split("\n---\n", 1)[0]:
orphans.append(page.name)
if re.search(r"^title: .*part \d+ of \d+", text, re.M | re.I):
numbered.append(page.name)
+297
View File
@@ -0,0 +1,297 @@
"""System pages and boards (round 5, package 6).
NAOMI opened on hod2bios.zip, a set 13 of the 19 emulators listing it read
for one game. Lines read other names for one file as files to get: "Start
with: fdsbios.zip, disksys.rom". Twelve neogeo.zip were on five pages, the
SNK ones closed under Variants. The Atom, two MZ computers, a sound chip and
a toy had board pages, and the NAOMI and Atomiswave sets under the
Dreamcast folder stayed off their boards' pages. ScummVM's files were on
three pages, one section named ".variants". Board pages said their name
three times before the first file.
"""
from __future__ import annotations
import json
import re
import sys
import types
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import generate_site as gs # noqa: E402
import siterender # noqa: E402
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
SYSTEMS = CONTENT / "systems"
def _file(name: str, char: str, path: str = "") -> dict:
return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096,
"path": path or f"bios/Sega/NAOMI/{name}"}
def _consumers(reads: dict[str, list[tuple[str, dict]]]) -> types.SimpleNamespace:
"""Consumers where each sha1 is read by (emulator, entry) pairs, an
emulator's entries kept in one list as a profile keeps them."""
files_of: dict[str, list] = {}
entries: dict[str, dict[str, int]] = {}
emulators: dict[str, set[str]] = {}
for sha1, read_by in reads.items():
for emulator, entry in read_by:
files_of.setdefault(emulator, []).append(entry)
entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1
emulators.setdefault(sha1, set()).add(emulator)
return types.SimpleNamespace(
platforms={}, emulators=emulators, emulator_entries=entries,
emulator_files=files_of, installed={}, platform_systems={},
platform_destinations={}, _db={"files": {}},
)
def _page(stem: str) -> str:
path = SYSTEMS / f"{stem}.md"
if not path.is_file():
raise unittest.SkipTest(f"systems/{stem}.md not generated")
return path.read_text(encoding="utf-8")
def _start_line(text: str) -> str:
match = re.search(r"^\*\*(Start with|No file needed)\*\*.*$", text, re.M)
return match[0] if match else ""
def _database() -> dict:
path = REPO_ROOT / "database.json"
if not path.is_file():
raise unittest.SkipTest("database.json is not built")
return json.loads(path.read_text(encoding="utf-8"))
class AStartFileIsNoOnesExtra(unittest.TestCase):
"""6.1: a file most of whose readers take beside another one is no
start file, whoever requires it."""
def test_the_board_opens_on_its_bios_alone(self) -> None:
naomi, hod2 = _file("naomi.zip", "1"), _file("hod2bios.zip", "2")
both = [(f"flycast{n}", {"name": "hod2bios.zip", "required": True}) for n in range(2)]
extra = [(f"mame{n}", {"name": "hod2bios.zip", "required": False}) for n in range(3)]
consumers = _consumers({
"1" * 40: [(e, {"name": "naomi.zip", "required": True}) for e, _ in both + extra],
"2" * 40: both + extra,
})
self.assertEqual(gs._start_with([naomi, hod2], consumers),
["**Start with**: `naomi.zip`.", ""])
def test_the_naomi_page_opens_on_naomi_zip(self) -> None:
self.assertEqual(_start_line(_page("arcade-sega-naomi")), "**Start with**: `naomi.zip`.")
def test_a_file_several_require_beside_the_lead_stays(self) -> None:
bios7, bios9 = _file("bios7.bin", "3"), _file("bios9.bin", "4")
consumers = _consumers({
"3" * 40: [(e, {"name": "bios7.bin", "required": True}) for e in ("a", "b")],
"4" * 40: [(e, {"name": "bios9.bin", "required": True}) for e in ("a", "b")],
})
self.assertEqual(gs._start_with([bios7, bios9], consumers)[0],
"**Start with**: `bios7.bin`, `bios9.bin`.")
def test_some_files_each_is_not_every_file_for_one(self) -> None:
"""The SNES said bsnes and four more require 22 files."""
files = [_file(f"chip{n}.rom", str(n)) for n in range(1, 4)]
reads = {f["sha1"]: [(f"free{k}", {"name": f["name"], "required": False,
"hle_fallback": True}) for k in range(5)]
for f in files}
reads["1" * 40].append(("bsnes", {"name": "chip1.rom", "required": True}))
reads["2" * 40].append(("jgenesis", {"name": "chip2.rom", "required": True}))
reads["3" * 40].append(("jgenesis", {"name": "chip3.rom", "required": True}))
line = gs._start_with(files, _consumers(reads))[0]
self.assertIn("require some of these files: ", line)
class OtherNamesForOneSlotReadAsAlternatives(unittest.TestCase):
"""6.2: "Start with: fdsbios.zip, disksys.rom" read as two files."""
def test_the_other_name_is_read_instead(self) -> None:
disk, fds = _file("disksys.rom", "5"), _file("fdsbios.zip", "6")
consumers = _consumers({
"5" * 40: [(e, {"name": "disksys.rom", "required": True})
for e in ("mesen", "nestopia", "fceumm")],
"6" * 40: [("fbneo", {"name": "fdsbios.zip", "required": True})],
})
self.assertEqual(gs._start_with([disk, fds], consumers)[0],
"**Start with**: `disksys.rom`. FinalBurn Neo reads `fdsbios.zip` instead.")
def test_a_card_read_under_another_name_says_both(self) -> None:
disk, card = _file("disksys.rom", "5"), _file("long name (1986).bin", "7")
consumers = _consumers({
"5" * 40: [(e, {"name": "disksys.rom", "required": True}) for e in ("a", "b")],
"7" * 40: [("c", {"name": "short.rom", "required": True})],
})
self.assertIn("reads `long name (1986).bin` (as `short.rom`) instead.",
gs._start_with([disk, card], consumers)[0])
def test_a_regional_line_names_one_file_per_reader(self) -> None:
mega_cd = _page("sega").split("{ #mega-cd }", 1)[1].split("\n## ", 1)[0]
regions = [line for line in mega_cd.splitlines() if line.startswith("- **")]
self.assertTrue(regions)
for line in regions:
lead = line.split(". ", 1)[0]
self.assertEqual(len(re.findall(r"`[^`]+`", lead)), 1, line)
def test_the_fds_line(self) -> None:
fds = _page("nintendo").split("{ #famicom-disk-system }", 1)[1]
self.assertRegex(_start_line(fds), r"^\*\*Start with\*\*: `disksys.rom`\. FinalBurn Neo "
r"reads \[?`fdsbios.zip`.* instead\.$")
class TheNeoGeoSetsSitWithTheNeoGeo(unittest.TestCase):
"""6.3: every neogeo.zip has its card on SNK's pages, side by side and
open, and the ares sentence names the card."""
def test_every_neogeo_zip_is_on_snk(self) -> None:
db = _database()
sha1s = [sha1 for sha1, f in db["files"].items() if f["name"] == "neogeo.zip"]
held = "".join(path.read_text(encoding="utf-8") for path in SYSTEMS.glob("snk*.md"))
if not held:
self.skipTest("SNK pages not generated")
self.assertEqual([s for s in sha1s if f'id="sha1-{s[:12]}"' not in held], [])
others = "".join(path.read_text(encoding="utf-8")
for path in SYSTEMS.glob("arcade*.md"))
self.assertEqual([s for s in sha1s if f'id="sha1-{s[:12]}"' in others], [])
def test_the_sets_are_open_and_together(self) -> None:
section = _page("snk").split("{ #neo-geo }", 1)[1].split("\n## ", 1)[0]
titles = re.findall(r'class="rb-file-title"[^>]*><code>([^<]+)</code>', section)
sets = [n for n, name in enumerate(titles) if name == "neogeo.zip"]
self.assertGreater(len(sets), 1)
self.assertEqual(sets, list(range(len(sets))))
variants = section.split("Variants (", 1)[1] if "Variants (" in section else ""
self.assertNotIn("<code>neogeo.zip</code>", variants)
def test_ares_is_told_the_card_it_reads(self) -> None:
self.assertIn("ares reads `neo-epo.bin` and `sp-45.sp1` instead.", _page("snk"))
class ABoardIsAnArcadeMachine(unittest.TestCase):
"""6.4: computers, a sound chip and a toy had board pages."""
def test_no_board_has_a_parent_outside_arcade(self) -> None:
wrong = sorted(board for board in siterender.arcade_boards()
if board in siterender.SYSTEM_PARENTS)
self.assertEqual(wrong, [])
def test_no_board_page_for_a_machine_filed_elsewhere(self) -> None:
if not SYSTEMS.is_dir():
self.skipTest("pages not generated")
for stem in ("arcade-acorn-atom", "arcade-sharp-mz-2000", "arcade-sharp-mz-2500",
"arcade-yamaha-ym2608", "arcade-miuchiz"):
self.assertFalse((SYSTEMS / f"{stem}.md").exists(), stem)
def test_the_computers_sets_are_on_their_makers_page(self) -> None:
acorn = _page("acorn")
self.assertIn("{ #atom }", acorn)
self.assertIn("<code>atom_gdos.zip</code>", acorn)
cards = re.findall(r'class="rb-file-title"[^>]*><code>([^<]+)</code>', _page("sharp"))
self.assertEqual(cards.count("mz2000.zip"), 2)
class TheDreamcastBoardsAreFiledByBoard(unittest.TestCase):
"""6.5: the NAOMI and Atomiswave sets under the Dreamcast folder."""
def test_the_board_sets_left_the_dreamcast(self) -> None:
sega = _page("sega")
dreamcast = sega.split("{ #dreamcast }", 1)[1].split("\n## ", 1)[0]
for name in ("naomi.zip", "awbios.zip", "naomi2.zip"):
self.assertNotIn(f"<code>{name}</code>", dreamcast)
self.assertIn("(/retrobios/systems/arcade-sega-naomi/)", dreamcast)
self.assertIn("(/retrobios/systems/arcade-sammy-atomiswave/)", dreamcast)
def test_every_naomi_set_is_on_its_board(self) -> None:
db = _database()
page = _page("arcade-sega-naomi")
missing = [sha1 for sha1, f in db["files"].items()
if f["name"] == "naomi.zip" and f'id="sha1-{sha1[:12]}"' not in page]
self.assertEqual(missing, [])
def test_the_dreamcast_arcade_id_leads_to_naomi(self) -> None:
siterender.reset_console_pages()
for board in siterender.arcade_boards():
siterender.record_board_section(board)
try:
self.assertEqual(siterender.system_section("sega-dreamcast-arcade"),
("arcade", "Sega NAOMI"))
finally:
siterender.reset_console_pages()
class EnginesGoByEngine(unittest.TestCase):
"""6.6: no section named after a folder of variants or a file."""
def test_no_section_or_row_is_a_folder_or_a_file(self) -> None:
db = _database()
names = set(db["indexes"]["by_name"])
index = _page("index")
rows = re.findall(r"^\| \[([^\]]+)\]\(", index, re.M)
headings = [h.strip() for path in SYSTEMS.glob("*.md")
for h in re.findall(r"^## ([^{\n]+)", path.read_text(encoding="utf-8"), re.M)]
wrong = sorted({n for n in rows + headings if n.startswith(".") or n in names})
self.assertEqual(wrong, [])
def test_scummvm_has_one_page(self) -> None:
self.assertFalse((SYSTEMS / "other-scummvm.md").exists())
self.assertIn("## ScummVM {", _page("scummvm"))
self.assertNotIn("](/retrobios/systems/other/#scummvm)", _page("index"))
def test_a_folder_named_by_a_profile_key_reads_the_engine(self) -> None:
index = _page("index")
self.assertIn("| [EasyRPG Player](/retrobios/systems/rpg-maker/#easyrpg) |", index)
self.assertIn("| [SDLPAL](", index)
def test_moved_pages_redirect(self) -> None:
if not SYSTEMS.is_dir():
self.skipTest("pages not generated")
for old, new in gs.SYSTEM_PAGES_MOVED.items():
self.assertFalse((CONTENT / old).exists(), old)
self.assertTrue((CONTENT / new).exists(), new)
redirects = CONTENT.parents[1] / "generated" / "redirects.json"
if redirects.is_file():
table = json.loads(redirects.read_text(encoding="utf-8"))
self.assertEqual(table.get("/systems/other-scummvm/"), "/systems/scummvm/")
class OneLabelOnceOnABoardPage(unittest.TestCase):
"""6.7, 6.8, 6.9, 6.10: the words a board or part page repeats."""
def test_a_label_naming_files_installs_these_files(self) -> None:
block = "\n".join(gs._console_install_block("Other arcade files", [("mame", "arcade")]))
self.assertIn("<summary>Install these files</summary>", block)
block = "\n".join(gs._console_install_block("Sega NAOMI", [("mame", "arcade")]))
self.assertIn("<summary>Install the files of Sega NAOMI</summary>", block)
def test_a_board_page_has_no_heading_of_its_name(self) -> None:
page = _page("arcade-sega-naomi")
self.assertNotRegex(page, r"^## Sega NAOMI", )
self.assertNotIn("](/retrobios/systems/arcade-sega-naomi/#sega-naomi)",
_page("index"))
def test_no_part_says_where_it_sits(self) -> None:
if not SYSTEMS.is_dir():
self.skipTest("pages not generated")
said = sorted(p.name for p in SYSTEMS.glob("*.md")
if "\nPart of [" in p.read_text(encoding="utf-8"))
self.assertEqual(said, [])
def test_a_part_goes_by_the_emulators_name(self) -> None:
titles = [re.search(r"^title: (.*)$", p.read_text(encoding="utf-8"), re.M)[1].strip("'")
for p in SYSTEMS.glob("other-mame*.md")]
if not titles:
self.skipTest("pages not generated")
self.assertEqual([t for t in titles if not t.startswith(("MAME files", "Other: MAME"))],
[])
if __name__ == "__main__":
unittest.main()