589894c50d
В репозитории нет ни байта чужих данных, но есть знание, откуда их взять: 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
412 lines
18 KiB
Python
Executable File
412 lines
18 KiB
Python
Executable File
#!/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())
|