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
This commit is contained in:
Executable
+411
@@ -0,0 +1,411 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user