diff options
| -rw-r--r-- | .devcontainer/devcontainer.json | 9 | ||||
| -rw-r--r-- | Cargo.toml | 8 | ||||
| -rw-r--r-- | testdata/nres/.gitignore | 2 | ||||
| -rw-r--r-- | testdata/rsli/.gitignore | 2 | ||||
| -rw-r--r-- | tools/README.md | 36 | ||||
| -rw-r--r-- | tools/init_testdata.py | 204 |
6 files changed, 261 insertions, 0 deletions
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f884a10 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "image": "mcr.microsoft.com/devcontainers/rust:latest", + "customizations": { + "vscode": { + "extensions": ["rust-lang.rust-analyzer"] + } + }, + "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"] +} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..34c501a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[profile.release] +codegen-units = 1 +lto = true +strip = true diff --git a/testdata/nres/.gitignore b/testdata/nres/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/testdata/nres/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore
\ No newline at end of file diff --git a/testdata/rsli/.gitignore b/testdata/rsli/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/testdata/rsli/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore
\ No newline at end of file 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()) |
