#!/usr/bin/env python3 """fetch_orig.py — забирает ВНЕШНИЕ исходные данные в assets/orig/. Репозиторий SprPoP не хранит ни байта чужих данных: `assets/orig/` целиком в .gitignore. Хранится вместо них ЗНАНИЕ, откуда эти данные берутся, — таблица SOURCES ниже, и этот скрипт, который по ней всё выкачивает. python3 tools/fetch_orig.py # SDLPoP + музыка (чего нет) python3 tools/fetch_orig.py --check # что уже на месте, ничего не качать python3 tools/fetch_orig.py --list # печать таблицы источников python3 tools/fetch_orig.py sdlpop --force --ref v1.23 Что откуда: SDLPoP (GPLv3, github.com/NagyD/SDLPoP) — распакованные ресурсы DOS-версии (`data/`: спрайты, палитры, LEVELS/res20xx.bin, DIGISND*.DAT) и исходники (`src/`), которые нам одновременно ЭТАЛОН МЕХАНИКИ. Тянем архивом ветки или тега, а не git-клоном: в assets/orig/ не нужен вложенный .git, а зафиксировать версию всё равно надо — пишем её в SDLPoP/.fetch.json. PoP1_DOS_music (popot.org, 43 МБ) — записи саундтрека DOS-версии в четырёх наборах (flac/mp3/ogg/ogg_MT-32), из них `pop_pack_music.py` делает треки для CBL. Ссылку берём ИЗ САМОГО SDLPoP (ReadMe.amigaos4, секция «AUDIO IS SLOW/AWFULL»), чтобы она не протухла молча вместе с нашей копией; если секцию не нашли — падаем на константу MUSIC_URL_FALLBACK. MSDOS/ — оригинальная поставка PoP 1.0 для MS-DOS. НЕ качается: это копирайтный дистрибутив, качать его неоткуда и незачем. Он НЕОБЯЗАТЕЛЕН — всё, что нужно сборке (уровни, звуки), лежит в SDLPoP. Если папка у вас есть, её звуковые .dat можно включить явно: `make SND_SRC=msdos ...` (см. assets/orig/README.md). """ import argparse import io import json import os import re import shutil import sys import tarfile import time import urllib.error import urllib.request import zipfile from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import paths as P # раскладка каталогов SprPoP (tools/paths.py) # --- источники ------------------------------------------------------------- SDLPOP_REPO = "https://github.com/NagyD/SDLPoP" SDLPOP_REF = "master" # ветка по умолчанию = «последняя версия» SDLPOP_API = "https://api.github.com/repos/NagyD/SDLPoP" # Секция «AUDIO IS SLOW/AWFULL» в этом файле SDLPoP держит ссылку на записи. MUSIC_README = "ReadMe.amigaos4" MUSIC_URL_FALLBACK = \ "https://www.popot.org/get_the_games/various/PoP1_DOS_music.zip" UA = {"User-Agent": "SprPoP-fetch-orig/1"} CACHE = P.ORIG / ".cache" # скачанные архивы; переживает make clean SOURCES = [ ("sdlpop", P.SDLPOP, SDLPOP_REPO, "ресурсы DOS-версии (data/) + исходники-эталон (src/)"), ("music", P.MUSIC_ROOT, MUSIC_URL_FALLBACK, "записи саундтрека DOS-версии, 4 набора (flac/mp3/ogg/ogg_MT-32)"), ("MSDOS (необязательно, вручную)", P.MSDOS, "дистрибутив игры", "оригинальные .dat; нужен только для SND_SRC=msdos"), ] # --- мелочи ---------------------------------------------------------------- def say(msg): print("fetch: " + msg, flush=True) def fetch_url(url, dest, expect_type=None): """Скачать url в dest (через .part), с индикатором на одной строке. Если dest уже есть и его размер совпал с Content-Length — не качаем. """ req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=60) as r: total = int(r.headers.get("Content-Length") or 0) ctype = r.headers.get("Content-Type", "") if expect_type and expect_type not in ctype: say(f"ПРЕДУПРЕЖДЕНИЕ: {url} отдал Content-Type {ctype!r}") if dest.exists() and total and dest.stat().st_size == total: say(f"уже скачано: {dest.name} ({total} Б)") return dest dest.parent.mkdir(parents=True, exist_ok=True) tmp = dest.with_suffix(dest.suffix + ".part") # Проценты — только в терминал: в перенаправленном выводе (лог сборки, # CI) \r не схлопывается, и одна закачка даёт полторы сотни строк. tty = sys.stdout.isatty() got, t0 = 0, time.time() with open(tmp, "wb") as f: while True: chunk = r.read(256 * 1024) if not chunk: break f.write(chunk) got += len(chunk) if total and tty: print("\r %s: %3d%% (%d/%d КБ)" % (dest.name, got * 100 // total, got >> 10, total >> 10), end="", flush=True) print("%s %s: готово, %d КБ за %.1f с%s" % ("\r" if tty else "", dest.name, got >> 10, time.time() - t0, " " * 20 if tty else "")) tmp.replace(dest) return dest def safe_members(names, root): """Проверка архива на выход за пределы каталога распаковки.""" for n in names: p = Path(n) if p.is_absolute() or ".." in p.parts: sys.exit(f"архив содержит небезопасный путь {n!r} — распаковка отменена") return root def backup_dir(dest): """Сложить текущий каталог в .cache архивом — перед заменой.""" CACHE.mkdir(parents=True, exist_ok=True) tar = CACHE / ("%s-backup-%s.tar.gz" % (dest.name, time.strftime("%Y%m%d-%H%M%S"))) with tarfile.open(tar, "w:gz") as t: t.add(dest, arcname=dest.name) say(f"старая копия сохранена: {tar} ({tar.stat().st_size >> 10} КБ)") return tar def swap_dir(new, dest): """Поставить свежераспакованный каталог на место старого.""" dest.parent.mkdir(parents=True, exist_ok=True) old = dest.with_name(dest.name + ".old") if old.exists(): shutil.rmtree(old) if dest.exists(): dest.rename(old) new.rename(dest) if old.exists(): shutil.rmtree(old) # --- SDLPoP ---------------------------------------------------------------- def sdlpop_head(ref): """(sha, дата) верхушки ref на GitHub; (None, None) если API недоступен.""" try: req = urllib.request.Request(f"{SDLPOP_API}/commits/{ref}", headers=UA) with urllib.request.urlopen(req, timeout=30) as r: d = json.load(r) return d["sha"], d["commit"]["committer"]["date"] except (urllib.error.URLError, KeyError, ValueError) as e: # Лимит запросов GitHub или отсутствие сети: не повод падать — # просто не сможем сказать «у нас уже последняя». say(f"GitHub API недоступен ({e}); версию не проверяю") return None, None def stamp_path(dest): return dest / ".fetch.json" def manifest(root): """{относительный путь: sha256} по всему дереву, кроме служебного.""" import hashlib out = {} for f in sorted(root.rglob("*")): if not f.is_file() or f.name == ".fetch.json": continue rel = str(f.relative_to(root)) h = hashlib.sha256() with open(f, "rb") as fh: for chunk in iter(lambda: fh.read(1 << 16), b""): h.update(chunk) out[rel] = h.hexdigest() return out def local_edits(dest): """Файлы, изменённые ПОСЛЕ распаковки: (изменённые, добавленные). Считается по манифесту из .fetch.json. Нет манифеста — вернуть None: происхождение копии неизвестно, и трогать её нельзя. """ base = read_stamp(dest).get("files") if not base: return None now = manifest(dest) changed = sorted(k for k, v in now.items() if k in base and base[k] != v) added = sorted(k for k in now if k not in base) return changed, added def read_stamp(dest): try: return json.loads(stamp_path(dest).read_text()) except (OSError, ValueError): return {} def fetch_sdlpop(ref=SDLPOP_REF, force=False): dest = P.SDLPOP sha, date = sdlpop_head(ref) have = read_stamp(dest) if dest.exists() and (dest / "data").is_dir(): if sha and have.get("sha") == sha and not force: say(f"SDLPoP уже последний: {ref} {sha[:8]} от {date[:10]}") return # ЗАМЕНА КАТАЛОГА — ТОЛЬКО ОСОЗНАННО. Наша копия SDLPoP не просто # справочник: в неё внесены отладочные врезки (POP_TRACE — покадровая # трасса Кида для сверки с портом, дампы спрайтов и палитры), и # перекачка их сотрёт. Поэтому существующее дерево не трогаем без # --force, а что именно потеряется — говорим заранее. edits = local_edits(dest) if edits is None: note = ("происхождение копии неизвестно (нет манифеста .fetch.json) " "— возможно, в ней есть ваши правки") else: changed, added = edits if changed or added: note = "локально изменено: %s%s" % ( ", ".join(changed[:8]) + ("…" if len(changed) > 8 else ""), "; добавлено %d файлов" % len(added) if added else "") else: note = "" if not force: say("SDLPoP уже на месте" + (f"; {note}" if note else "")) say(" перекачать поверх: make FETCH_ARGS=--force fetch-sdlpop") say(" (правки будут сохранены в бэкап, см. ниже)") return if note: say("ВНИМАНИЕ: " + note) backup_dir(dest) # Тег и ветка лежат по разным путям codeload; пробуем оба. urls = [f"https://codeload.github.com/NagyD/SDLPoP/tar.gz/refs/heads/{ref}", f"https://codeload.github.com/NagyD/SDLPoP/tar.gz/refs/tags/{ref}"] CACHE.mkdir(parents=True, exist_ok=True) tar = CACHE / f"SDLPoP-{ref.replace('/', '_')}.tar.gz" last = None for u in urls: try: say(f"качаю SDLPoP {ref} <- {u}") fetch_url(u, tar) last = None break except urllib.error.HTTPError as e: last = e if last is not None: sys.exit(f"не удалось скачать SDLPoP {ref!r}: {last}") tmp = P.ORIG / ".SDLPoP.new" if tmp.exists(): shutil.rmtree(tmp) tmp.mkdir(parents=True) with tarfile.open(tar, "r:gz") as t: names = t.getnames() safe_members(names, tmp) # В архиве GitHub всё лежит в одном каталоге SDLPoP-/ — снимаем его. top = os.path.commonpath([n for n in names if n]) if names else "" t.extractall(tmp) src = tmp / top if top else tmp inner = P.ORIG / ".SDLPoP.unpacked" if inner.exists(): shutil.rmtree(inner) src.rename(inner) shutil.rmtree(tmp, ignore_errors=True) if not (inner / "data").is_dir() or not (inner / "src").is_dir(): shutil.rmtree(inner) sys.exit("в архиве SDLPoP нет data/ или src/ — раскладка репозитория " "изменилась, чините fetch_orig.py") # Манифест распакованного дерева: по нему следующий fetch отличит наши # правки от нетронутых файлов и не сотрёт их молча. stamp_path(inner).write_text(json.dumps( {"repo": SDLPOP_REPO, "ref": ref, "sha": sha, "date": date, "fetched": time.strftime("%Y-%m-%d %H:%M:%S"), "files": manifest(inner)}, ensure_ascii=False, indent=2) + "\n") swap_dir(inner, dest) say(f"SDLPoP: {ref} {(sha or '?')[:8]} -> {dest}") # --- музыка ---------------------------------------------------------------- def music_url(): """Ссылка на архив записей: из ReadMe SDLPoP, иначе константа.""" rm = P.SDLPOP / MUSIC_README if rm.is_file(): text = rm.read_text(errors="replace") m = re.search(r"https?://\S*[Mm]usic\S*\.zip", text) if m: url = m.group(0).rstrip(".,)") say(f"ссылка на музыку из SDLPoP/{MUSIC_README}: {url}") return url say(f"в SDLPoP/{MUSIC_README} ссылки не нашлось — беру запасную") else: say(f"нет SDLPoP/{MUSIC_README} (сначала `fetch_orig.py sdlpop`) — " "беру запасную ссылку") return MUSIC_URL_FALLBACK def fetch_music(force=False): dest = P.MUSIC_ROOT if dest.is_dir() and not force: have = [d for d, _ in P.MUSIC_FORMATS.values() if (dest / d).is_dir()] if len(have) == len(P.MUSIC_FORMATS): say(f"музыка уже на месте: {dest} ({len(have)} набора)") return say(f"музыка неполная (наборы: {', '.join(have) or 'нет'}) — перекачиваю") url = music_url() CACHE.mkdir(parents=True, exist_ok=True) zpath = CACHE / os.path.basename(url) fetch_url(url, zpath, expect_type="zip") with zipfile.ZipFile(zpath) as z: names = z.namelist() safe_members(names, P.ORIG) top = {Path(n).parts[0] for n in names if n} if top != {dest.name}: sys.exit(f"в архиве музыки ожидался один каталог {dest.name}/, " f"а там {sorted(top)} — чините fetch_orig.py") tmp = P.ORIG / ".music.new" if tmp.exists(): shutil.rmtree(tmp) say(f"распаковываю {len(names)} файлов") z.extractall(tmp) swap_dir(tmp / dest.name, dest) shutil.rmtree(tmp, ignore_errors=True) got = [d for d, _ in P.MUSIC_FORMATS.values() if (dest / d).is_dir()] say(f"музыка: наборы {', '.join(sorted(got))} -> {dest}") # --- отчёт ----------------------------------------------------------------- def do_list(): print("Внешние источники SprPoP (assets/orig/ в репозиторий не идёт):\n") for name, path, where, what in SOURCES: print(f" {name}") print(f" куда: {path}") print(f" откуда: {where}") print(f" что: {what}\n") def do_check(): ok = True st = read_stamp(P.SDLPOP) if (P.SDLPOP / "data").is_dir() and (P.SDLPOP / "src").is_dir(): v = f"{st.get('ref', '?')} {(st.get('sha') or '?')[:8]}" print(f" SDLPoP есть ({v}, {st.get('fetched', 'версия неизвестна')})") else: print(" SDLPoP НЕТ -> make fetch-sdlpop") ok = False have = [d for d, _ in P.MUSIC_FORMATS.values() if (P.MUSIC_ROOT / d).is_dir()] if len(have) == len(P.MUSIC_FORMATS): print(f" музыка есть (наборы: {', '.join(sorted(have))})") elif have: print(f" музыка ЧАСТЬ (только {', '.join(sorted(have))}) -> make fetch-music") ok = False else: print(" музыка НЕТ -> make fetch-music") ok = False if P.MSDOS.is_dir(): print(" MSDOS есть (необязательно; звуки оттуда — make SND_SRC=msdos)") else: print(" MSDOS нет (необязательно: звуки и уровни берутся из SDLPoP)") return 0 if ok else 1 def main(): ap = argparse.ArgumentParser( description="скачать внешние исходные данные SprPoP в assets/orig/") ap.add_argument("what", nargs="*", default=["all"], help="что качать: all (умолчание), sdlpop, music") ap.add_argument("--force", action="store_true", help="качать заново, даже если данные на месте") ap.add_argument("--ref", default=SDLPOP_REF, help=f"ветка или тег SDLPoP (по умолчанию {SDLPOP_REF})") ap.add_argument("--check", action="store_true", help="только показать, что уже на месте") ap.add_argument("--list", action="store_true", help="только показать таблицу источников") a = ap.parse_args() if a.list: do_list() return 0 if a.check: return do_check() what = set(a.what) or {"all"} bad = what - {"all", "sdlpop", "music"} if bad: ap.error("не знаю, что такое %s; есть: all, sdlpop, music" % ", ".join(sorted(bad))) if "all" in what: what = {"sdlpop", "music"} P.ORIG.mkdir(parents=True, exist_ok=True) if "sdlpop" in what: fetch_sdlpop(a.ref, a.force) if "music" in what: fetch_music(a.force) say("готово; дальше — make resources") return 0 if __name__ == "__main__": sys.exit(main())