"""Import game catalogs from No-Intro and Redump DATs, one file per system. The games-on-card mode identifies a game from its bytes: a cartridge by the crc32 and size a ZIP central directory gives without inflating, a disc by the first data track or the serial its header carries. These catalogs hold those keys with the title and the territory the DAT names the game for. They are game catalogues, not dump catalogues of BIOS files, so they live in games/catalogs/ and never in provenance/. python -m scripts.scraper.game_dat_importer --fetch python -m scripts.scraper.game_dat_importer --fetch --source redump python -m scripts.scraper.game_dat_importer --offline --floor python -m scripts.scraper.game_dat_importer --source no-intro --pack no-intro.zip No-Intro comes from the auto-datfile-generator release asset (Dat-o-Matic bans automated clients), Redump from redump.info's per-system datfile route. The mirror is dantob's fork: hugo19941994's original has failed its daily rebuild since Dat-o-Matic's 2026-07 re-layout (no-intro.zip last rebuilt 2026-07-08), and the fork carries the fix (d4a9d769, 2026-07-31). Every download is cached under .cache/game-dats/ with what the server said about it, so a second run reads nothing from the network and writes the same bytes. A DAT's region tag is a market label, never the value a core computes. """ from __future__ import annotations import argparse import gzip import io import json import re import sys import urllib.request import zipfile from collections.abc import Iterator from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path _SCRIPTS_DIR = str(Path(__file__).resolve().parents[1]) if _SCRIPTS_DIR not in sys.path: sys.path.insert(0, _SCRIPTS_DIR) from artifacts import copy_stream_atomic, write_if_changed, write_text_atomic # noqa: E402 from common import parse_untrusted_xml # noqa: E402 from region import REGIONS, WORLD # noqa: E402 from region_audit import NOINTRO # noqa: E402 from .base_scraper import github_headers # noqa: E402 SOURCES = ("no-intro", "redump") CACHE_DIR = Path(".cache") / "game-dats" OUTPUT_DIR = Path("games") / "catalogs" NOINTRO_MIRROR = "https://github.com/dantob/auto-datfile-generator" NOINTRO_RELEASE = "Daily_Rebuild" NOINTRO_RELEASE_API = ( NOINTRO_MIRROR.replace("https://github.com/", "https://api.github.com/repos/") + f"/releases/tags/{NOINTRO_RELEASE}" ) NOINTRO_ASSET = "no-intro.zip" REDUMP_DATFILE_URL = "https://redump.info/datfile/{code}" # DAT header name -> (repository system id, floor). The floor is the least # number of games the DAT may hold before an import is refused as truncated: # 95 % of the count read on 2026-11-03, rounded down. NOINTRO_DATS: dict[str, tuple[str, int]] = { "Nintendo - Super Nintendo Entertainment System": ("nintendo-snes", 3920), "Nintendo - Game Boy": ("nintendo-gb", 1897), "Nintendo - Game Boy Color": ("nintendo-gbc", 1935), "Nintendo - Game Boy Advance": ("nintendo-gba", 3356), "Nintendo - Nintendo 64 (BigEndian)": ("nintendo-64", 1098), "Sega - Mega Drive - Genesis": ("sega-megadrive", 2718), "Nintendo - Nintendo Entertainment System (Headered)": ("nintendo-nes", 4276), "Nintendo - Nintendo DS (Decrypted)": ("nintendo-ds", 7289), "NEC - PC Engine - TurboGrafx-16": ("nec-pc-engine", 438), "Sega - Master System - Mark III": ("sega-mastersystem", 665), "Sega - Game Gear": ("sega-gamegear", 786), "Atari - Atari Lynx (LNX)": ("atari-lynx", 17), "Nintendo - Nintendo 64DD": ("nintendo-64dd", 33), "Nintendo - Family Computer Disk System (FDS)": ("nintendo-fds", 384), "Nintendo - Pokemon Mini": ("nintendo-pokemon-mini", 41), "Atari - Atari 7800 (A78)": ("atari-7800", 7), "Atari - Atari 5200": ("atari-5200", 197), "Mattel - Intellivision": ("intellivision", 197), "Coleco - ColecoVision": ("colecovision", 190), "Magnavox - Odyssey 2": ("odyssey2", 126), "GCE - Vectrex": ("vectrex", 45), "Nintendo - Satellaview": ("nintendo-satellaview", 570), "Nintendo - Sufami Turbo": ("nintendo-sufami-turbo", 12), "NEC - PC Engine SuperGrafx": ("nec-supergrafx", 4), "Sega - 32X": ("sega-32x", 202), "Bandai - WonderSwan": ("wonderswan", 244), "Bandai - WonderSwan Color": ("wonderswan-color", 229), "SNK - NeoGeo Pocket": ("snk-ngp", 12), "SNK - NeoGeo Pocket Color": ("snk-ngpc", 121), } # redump.info datfile code -> (DAT header name, repository system id, floor). REDUMP_DATS: dict[str, tuple[str, str, int]] = { "PSX": ("Sony - PlayStation", "sony-playstation", 10432), "PS2": ("Sony - PlayStation 2", "sony-playstation-2", 11252), "SS": ("Sega - Saturn", "sega-saturn", 2340), "MCD": ("Sega - Mega CD & Sega CD", "sega-megacd", 521), "DC": ("Sega - Dreamcast", "sega-dreamcast", 1442), "GC": ("Nintendo - GameCube", "nintendo-gamecube", 1920), "PCE": ("NEC - PC Engine CD & TurboGrafx CD", "nec-pc-engine-cd", 523), "NGCD": ("SNK - Neo Geo CD", "snk-neogeo-cd", 107), "3DO": ("3DO Interactive Multiplayer", "panasonic-3do", 635), } TERMS_READ = "2026-11-03" LICENCES: dict[str, dict] = { "no-intro": { "terms_url": "https://datomatic.no-intro.org/terms.html", "terms_title": "Data Usage License", "terms_updated": "2026-09-11", "read": TERMS_READ, "quote": ( 'Unless otherwise stated on the website, the data made available through ' 'this website ("Data") may be freely used, copied, reproduced, modified, ' "adapted, combined, published, distributed, and otherwise reused by anyone " "for any lawful purpose, to the extent that the website operator owns or " "controls the applicable rights or is authorized to grant such permission. " "No permission, attribution, citation, backlink, or acknowledgment of the " "website or its operators is required for such use." ), "mirror": { "repo": NOINTRO_MIRROR, "licence": "MIT", "scope": "the mirror's scripts only, the DATs it republishes stay under the terms above", }, }, "redump": { "terms_url": "https://redump.info/about", "read": TERMS_READ, "quote": ( "Dumpers only submit metadata and Redump only stores metadata. This " "information is considered public domain to be used however people see fit" ), }, } # Territory words the DATs use that scripts/region.py has no member slug # for. Each falls under the one super-region that contains it. EXTRA_TERRITORIES = { "Scandinavia": "europe", "Belgium": "europe", "Switzerland": "europe", "Austria": "europe", "Ireland": "europe", "United Kingdom": "uk", } _TERRITORY = {**{k: v for k, v in NOINTRO.items()}, **EXTRA_TERRITORIES} # A territory word with no slug: the group is a territory tag, but the # catalog cannot name it and records no region. _UNMAPPED_TERRITORIES = {"South Africa"} SERIAL_NOTE = ( "Serials are written as the DAT writes them, and the DAT uses both forms: " "'SLES-01971' and 'SLPS 02251' in the PlayStation DAT of the same day. A " "core reads the serial from the disc in its own spelling (SYSTEM.CNF holds " "'SLPS_022.51', DuckStation reports 'SLPS-02251'). No source fixes a rule " "joining the two, so the comparison rule is the reader's to state." ) _PAREN = re.compile(r"\(([^)]+)\)") _NOINTRO_MEMBER = re.compile(r"^(?P.+) \((?P\d{8}-\d{6})\)\.dat$") _DISPOSITION = re.compile(r'filename="(?P[^"]+)"') _REDUMP_FILE = re.compile( r"^(?P.+) - Datfile \((?P\d+)\) \((?P\d{4}-\d{2}-\d{2}) (?P