feat: добавить скрипты для инициализации тестовых данных и настройки окружения
Some checks failed
Test / cargo test (push) Failing after 58s
Some checks failed
Test / cargo test (push) Failing after 58s
This commit is contained in:
9
.devcontainer/devcontainer.json
Normal file
9
.devcontainer/devcontainer.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = ["crates/*"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
2
testdata/nres/.gitignore
vendored
Normal file
2
testdata/nres/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
2
testdata/rsli/.gitignore
vendored
Normal file
2
testdata/rsli/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -69,3 +69,39 @@ python3 tools/archive_roundtrip_validator.py validate \
|
|||||||
- `entries/*.bin` — payload-файлы.
|
- `entries/*.bin` — payload-файлы.
|
||||||
|
|
||||||
Имена файлов в `entries` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно.
|
Имена файлов в `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`.
|
||||||
|
|||||||
204
tools/init_testdata.py
Normal file
204
tools/init_testdata.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user