Files
Sprinter-SDCC/applications/SprPoP/tools/fetch_orig.py
T
snark13 589894c50d SprPoP: автономность — внешние данные качаются, а не хранятся
В репозитории нет ни байта чужих данных, но есть знание, откуда их взять:
tools/fetch_orig.py качает SDLPoP (ресурсы + исходники-эталон) и записи
саундтрека, причём адрес архива музыки читает из самого SDLPoP
(ReadMe.amigaos4, секция «AUDIO IS SLOW/AWFULL»); запасной адрес вшит
константой.  Цели: make fetch / fetch-sdlpop / fetch-music / fetch-check /
fetch-list.

Версия SDLPoP пишется в .fetch.json вместе с манифестом sha256 всего
дерева.  По нему следующий fetch отличает НАШИ отладочные врезки
(POP_TRACE — покадровая трасса Кида, дампы палитры и спрайтов) от
нетронутых файлов и не сносит их молча: без --force каталог не трогается
вовсе, с --force старая копия уезжает в бэкап .cache/.

MSDOS/ не качается и НЕ НУЖЕН: уровни и оцифровка берутся из SDLPoP.

У SprPoP теперь свой .gitignore, написанный так, чтобы стать корневым при
выделении в отдельный репозиторий (пути от корня приложения, ничего про
applications/).  Из корневого .gitignore тулчейна SprPoP-секция убрана,
чтобы две копии не разъезжались.  Заодно закрылась дыра: шаблон
applications/*/*/*.exe не покрывал артефакты в корне SprPoP.

assets/orig/README.md выведен из-под игнора — без него в чистом клоне не
написано, откуда брать данные, а это и есть смысл затеи.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 16:14:12 +03:00

412 lines
18 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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-<ref>/ — снимаем его.
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())