diff --git a/scripts/generate_site.py b/scripts/generate_site.py index a95277f2..c1fbf1c9 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -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:", diff --git a/scripts/siterender.py b/scripts/siterender.py index c24ada69..17cdfe50 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -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/// + 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///...).""" + (bios///...). 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", } diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index c19cc7b0..cae5191a 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -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("Variants (1)"):] + # Another revision of a set sits open beside it (round 5, 6.3). + block = page[page.index('id="sha1-' + "c" * 12 + '"'):] self.assertIn("
Collection path
bios/Entex/Adventure Vision/" ".variants/advision.zip.1c086064", block) self.assertIn('Batocera', block) diff --git a/tests/test_site_r2_systems.py b/tests/test_site_r2_systems.py index e883e797..d8869590 100644 --- a/tests/test_site_r2_systems.py +++ b/tests/test_site_r2_systems.py @@ -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.") diff --git a/tests/test_site_r3_nav.py b/tests/test_site_r3_nav.py index 5a7ef2b9..04c75b1c 100644 --- a/tests/test_site_r3_nav.py +++ b/tests/test_site_r3_nav.py @@ -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") diff --git a/tests/test_site_r3_p0.py b/tests/test_site_r3_p0.py index 58712552..6128d71c 100644 --- a/tests/test_site_r3_p0.py +++ b/tests/test_site_r3_p0.py @@ -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), []) diff --git a/tests/test_site_r4_p0.py b/tests/test_site_r4_p0.py index 866c9bb6..8b025903 100644 --- a/tests/test_site_r4_p0.py +++ b/tests/test_site_r4_p0.py @@ -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"), } diff --git a/tests/test_site_r4_systems.py b/tests/test_site_r4_systems.py index 40529d3f..75c5a9c2 100644 --- a/tests/test_site_r4_systems.py +++ b/tests/test_site_r4_systems.py @@ -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']*id="{re.escape(slug)}"', name) + self.assertRegex(html, rf"]*>Arcade: {re.escape(name)}", name) + self.assertNotRegex(html, rf"]*>{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 ": " or " 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) diff --git a/tests/test_site_r5_systems.py b/tests/test_site_r5_systems.py new file mode 100644 index 00000000..a8662903 --- /dev/null +++ b/tests/test_site_r5_systems.py @@ -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"[^>]*>([^<]+)', 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("neogeo.zip", 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("atom_gdos.zip", acorn) + cards = re.findall(r'class="rb-file-title"[^>]*>([^<]+)', _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"{name}", 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("Install these files", block) + block = "\n".join(gs._console_install_block("Sega NAOMI", [("mame", "arcade")])) + self.assertIn("Install the files of Sega NAOMI", 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()