aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-10 01:39:12 +0300
committerValentin Popov <valentin@popov.link>2026-02-10 01:39:12 +0300
commit828106ba810063801b14ea3d5b0d33802fcf0d2b (patch)
treefb03981ae853d20655db4e383406c2c19525388d /tools
parenta7dd18fa1d82ef6a561797afb2dd6dcb3f298e4a (diff)
downloadfparkan-828106ba810063801b14ea3d5b0d33802fcf0d2b.tar.xz
fparkan-828106ba810063801b14ea3d5b0d33802fcf0d2b.zip
feat: добавить скрипты для инициализации тестовых данных и настройки окружения
Diffstat (limited to 'tools')
-rw-r--r--tools/README.md36
-rw-r--r--tools/init_testdata.py204
2 files changed, 240 insertions, 0 deletions
diff --git a/tools/README.md b/tools/README.md
index 6059090..19de2e5 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -69,3 +69,39 @@ python3 tools/archive_roundtrip_validator.py validate \
- `entries/*.bin` — payload-файлы.
Имена файлов в `entries` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно.
+
+## `init_testdata.py`
+
+Скрипт инициализирует тестовые данные по сигнатурам архивов из спецификации:
+
+- `NRes` (`4E 52 65 73`);
+- `RsLi` (`NL 00 01`).
+
+Что делает утилита:
+
+- рекурсивно сканирует все файлы в `--input`;
+- копирует найденные `NRes` в `--output/nres/`;
+- копирует найденные `RsLi` в `--output/rsli/`;
+- сохраняет относительный путь исходного файла внутри целевого каталога;
+- создаёт целевые каталоги автоматически, если их нет.
+
+Базовый запуск:
+
+```bash
+python3 tools/init_testdata.py --input tmp/gamedata --output testdata
+```
+
+Если целевой файл уже существует, скрипт спрашивает подтверждение перезаписи (`yes/no/all/quit`).
+
+Для перезаписи без вопросов используйте `--force`:
+
+```bash
+python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force
+```
+
+Проверки надёжности:
+
+- `--input` должен существовать и быть каталогом;
+- если `--output` указывает на существующий файл, скрипт завершится с ошибкой;
+- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования;
+- если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`.
diff --git a/tools/init_testdata.py b/tools/init_testdata.py
new file mode 100644
index 0000000..4079cdb
--- /dev/null
+++ b/tools/init_testdata.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""
+Initialize test data folders by archive signatures.
+
+The script scans all files in --input and copies matching archives into:
+ --output/nres/<relative path>
+ --output/rsli/<relative path>
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import sys
+from pathlib import Path
+
+MAGIC_NRES = b"NRes"
+MAGIC_RSLI = b"NL\x00\x01"
+
+
+def is_relative_to(path: Path, base: Path) -> bool:
+ try:
+ path.relative_to(base)
+ except ValueError:
+ return False
+ return True
+
+
+def detect_archive_type(path: Path) -> str | None:
+ try:
+ with path.open("rb") as handle:
+ magic = handle.read(4)
+ except OSError as exc:
+ print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
+ return None
+
+ if magic == MAGIC_NRES:
+ return "nres"
+ if magic == MAGIC_RSLI:
+ return "rsli"
+ return None
+
+
+def scan_archives(input_root: Path, excluded_root: Path | None) -> list[tuple[Path, str]]:
+ found: list[tuple[Path, str]] = []
+ for path in sorted(input_root.rglob("*")):
+ if not path.is_file():
+ continue
+ if excluded_root and is_relative_to(path.resolve(), excluded_root):
+ continue
+
+ archive_type = detect_archive_type(path)
+ if archive_type:
+ found.append((path, archive_type))
+ return found
+
+
+def confirm_overwrite(path: Path) -> str:
+ prompt = (
+ f"File exists: {path}\n"
+ "Overwrite? [y]es / [n]o / [a]ll / [q]uit (default: n): "
+ )
+ while True:
+ try:
+ answer = input(prompt).strip().lower()
+ except EOFError:
+ return "quit"
+
+ if answer in {"", "n", "no"}:
+ return "no"
+ if answer in {"y", "yes"}:
+ return "yes"
+ if answer in {"a", "all"}:
+ return "all"
+ if answer in {"q", "quit"}:
+ return "quit"
+ print("Please answer with y, n, a, or q.")
+
+
+def copy_archives(
+ archives: list[tuple[Path, str]],
+ input_root: Path,
+ output_root: Path,
+ force: bool,
+) -> int:
+ copied = 0
+ skipped = 0
+ overwritten = 0
+ overwrite_all = force
+
+ type_counts = {"nres": 0, "rsli": 0}
+ for _, archive_type in archives:
+ type_counts[archive_type] += 1
+
+ print(
+ f"Found archives: total={len(archives)}, "
+ f"nres={type_counts['nres']}, rsli={type_counts['rsli']}"
+ )
+
+ for source, archive_type in archives:
+ rel_path = source.relative_to(input_root)
+ destination = output_root / archive_type / rel_path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+
+ if destination.exists():
+ if destination.is_dir():
+ print(
+ f"[error] destination is a directory, expected file: {destination}",
+ file=sys.stderr,
+ )
+ return 2
+
+ if not overwrite_all:
+ if not sys.stdin.isatty():
+ print(
+ "[error] destination file exists but stdin is not interactive. "
+ "Use --force to overwrite without prompts.",
+ file=sys.stderr,
+ )
+ return 2
+
+ decision = confirm_overwrite(destination)
+ if decision == "quit":
+ print("Aborted by user.")
+ return 130
+ if decision == "no":
+ skipped += 1
+ continue
+ if decision == "all":
+ overwrite_all = True
+
+ overwritten += 1
+
+ try:
+ shutil.copy2(source, destination)
+ except OSError as exc:
+ print(f"[error] failed to copy {source} -> {destination}: {exc}", file=sys.stderr)
+ return 2
+ copied += 1
+
+ print(
+ f"Done: copied={copied}, overwritten={overwritten}, skipped={skipped}, "
+ f"output={output_root}"
+ )
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Initialize test data by scanning NRes/RsLi signatures."
+ )
+ parser.add_argument(
+ "--input",
+ required=True,
+ help="Input directory to scan recursively.",
+ )
+ parser.add_argument(
+ "--output",
+ required=True,
+ help="Output root directory (archives go to nres/ and rsli/ subdirs).",
+ )
+ parser.add_argument(
+ "--force",
+ action="store_true",
+ help="Overwrite destination files without confirmation prompts.",
+ )
+ return parser
+
+
+def main() -> int:
+ args = build_parser().parse_args()
+
+ input_root = Path(args.input)
+ if not input_root.exists():
+ print(f"[error] input directory does not exist: {input_root}", file=sys.stderr)
+ return 2
+ if not input_root.is_dir():
+ print(f"[error] input path is not a directory: {input_root}", file=sys.stderr)
+ return 2
+
+ output_root = Path(args.output)
+ if output_root.exists() and not output_root.is_dir():
+ print(f"[error] output path exists and is not a directory: {output_root}", file=sys.stderr)
+ return 2
+
+ input_resolved = input_root.resolve()
+ output_resolved = output_root.resolve()
+ if input_resolved == output_resolved:
+ print("[error] input and output directories must be different.", file=sys.stderr)
+ return 2
+
+ excluded_root: Path | None = None
+ if is_relative_to(output_resolved, input_resolved):
+ excluded_root = output_resolved
+ print(f"Notice: output is inside input, skipping scan under: {excluded_root}")
+
+ archives = scan_archives(input_root, excluded_root)
+
+ output_root.mkdir(parents=True, exist_ok=True)
+ return copy_archives(archives, input_root, output_root, force=args.force)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())