Files
RustRaptor/tools/assemble_runtime.py
T

233 lines
11 KiB
Python

#!/usr/bin/env python3
"""Refresh the self-contained Linux runtime and optionally export a clean zip."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import zipfile
def digest(path):
with path.open("rb") as handle:
return hashlib.file_digest(handle, "sha256").hexdigest()
def checked_file(root, relative):
path = Path(relative)
if path.is_absolute() or ".." in path.parts:
raise ValueError(f"unsafe runtime path: {relative}")
resolved = (root / path).resolve(strict=True)
resolved.relative_to(root.resolve())
if not resolved.is_file():
raise ValueError(f"not a runtime file: {relative}")
return resolved
def default_data_source(package):
"""The portable package is authoritative when it has its own catalog."""
bundled = package / "data"
return bundled if (bundled / "INDEX").is_file() else package.parent / "data"
def checked_tree(root):
"""Select a complete contained file tree; never silently follow/skip symlink directories."""
root = root.resolve(strict=True)
if not root.is_dir():
raise ValueError(f"not a runtime data directory: {root}")
def raise_walk_error(error):
raise error
files = {}
for directory, directories, names in os.walk(root, followlinks=False, onerror=raise_walk_error):
parent = Path(directory)
for name in directories:
if (parent / name).is_symlink():
raise ValueError(f"symlink directory in runtime data: {parent / name}")
for name in names:
relative = (parent / name).relative_to(root).as_posix()
files[relative] = checked_file(root, relative)
return files
def select_data_files(package, source=None, hd_source=None):
"""Validate and select the complete runtime data without copying or mutating it."""
source = (source or default_data_source(package)).resolve(strict=True)
files = checked_tree(source)
entries = checked_file(source, "INDEX").read_text().splitlines()
catalog = {"INDEX"}
for row in entries:
if not row.strip():
continue
_, length, name = row.split()
original = checked_file(source, name)
if original.stat().st_size != int(length):
raise ValueError(f"catalog size mismatch: {name}")
catalog.add(Path(name).as_posix())
if hd_source is not None:
hd_source = hd_source.resolve(strict=True)
replacement = checked_tree(hd_source)
checked_file(hd_source, "hd.json")
if any(Path(name).parts[0] == "hd" for name in catalog):
raise ValueError("HD pack paths conflict with the original catalog")
files = {name: path for name, path in files.items() if Path(name).parts[0] != "hd"}
files.update({"hd/" + name: path for name, path in replacement.items()})
hd_frames = 0
if "hd/hd.json" in files:
hd = json.loads(files["hd/hd.json"].read_bytes())
hd_root = hd_source if hd_source is not None else source / "hd"
for sequence in hd["sequences"]:
for frame in sequence["frames"]:
payload = checked_file(hd_root, frame["file"])
name = "hd/" + Path(frame["file"]).as_posix()
if name not in files or files[name] != payload:
raise ValueError(f"HD frame is missing from runtime selection: {name}")
if name in catalog:
raise ValueError("HD pack paths conflict with the original catalog")
hd_frames += 1
# Original missions register these 27 SD/HD pairs in src/content.rs.
# An external legacy catalog need not carry new menu art; the bundled art
# is the only fallback, and existing external briefing files keep priority.
bundled = (package / "data").resolve()
required_briefings = [
f"briefings/sector_{sector}_wave_{wave}_{mode}.png"
for sector in range(1, 4) for wave in range(1, 10) for mode in ("sd", "hd")
]
if source != bundled and any(name not in files for name in required_briefings):
briefings = bundled / "briefings"
if briefings.is_symlink():
raise ValueError(f"symlink directory in bundled mission briefings: {briefings}")
for name, path in checked_tree(briefings).items():
files.setdefault("briefings/" + name, path)
for name in required_briefings:
if name not in files:
raise ValueError(f"required mission briefing is missing: {name}")
return files, entries, hd_frames
def iter_archive_files(package, archive, skipped, excluded_files=(), required_files=()):
"""Keep manifest payloads even when a parent has a build-cache name."""
required = {Path(name) for name in required_files}
required_directories = {parent for path in required for parent in path.parents}
for relative in required:
checked_file(package, relative)
def excluded(relative):
if relative in required or relative in required_directories:
return False
return (skipped.intersection(relative.parts)
or any(part.startswith(".runtime-data-") for part in relative.parts)
or (len(relative.parts) >= 4 and relative.parts[0] == "mods"
and relative.parts[2:4] == ("art", "source")))
files = []
for directory, directories, names in os.walk(package, followlinks=False):
parent = Path(directory)
directories[:] = [name for name in directories
if not excluded((parent / name).relative_to(package))]
for name in names:
path = parent / name
relative = path.relative_to(package)
if (not excluded(relative) and relative not in excluded_files and path.is_file()
and path.resolve() != archive):
files.append(path)
missing = required - {path.relative_to(package) for path in files}
if missing:
raise ValueError(f"runtime archive is missing required files: {sorted(map(str, missing))}")
yield from sorted(files)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-data", type=Path)
parser.add_argument("--hd-source", type=Path,
help="approved HD pack containing hd.json; defaults to SOURCE_DATA/hd")
parser.add_argument("--skip-build", action="store_true")
parser.add_argument("--zip", type=Path, help="export runtime, source, docs and mods; exclude build caches")
args = parser.parse_args()
package = Path(__file__).resolve().parents[1]
environment = os.environ.copy()
local_lib = Path.home() / ".local/lib"
environment["LD_LIBRARY_PATH"] = str(local_lib) + ":" + environment.get("LD_LIBRARY_PATH", "")
# Asset-review helpers also build a headless release. Keep the desktop
# artifact separate so concurrent validation cannot replace this binary.
desktop_target = package / "target/desktop-runtime"
if not args.skip_build:
subprocess.run(["cargo", "build", "--locked", "--release", "--target-dir", str(desktop_target),
"--bin", "raptor-rust", "--features", "desktop"],
cwd=package, env=environment, check=True)
executable = desktop_target / "release/raptor-rust"
if not executable.is_file():
raise ValueError("release executable is missing; run without --skip-build")
files, entries, hd_frames = select_data_files(package, args.source_data, args.hd_source)
staging = Path(tempfile.mkdtemp(prefix=".runtime-data-", dir=package))
try:
for name in sorted(files):
destination = staging / name
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(files[name], destination)
previous = package / ".runtime-data-previous"
if previous.exists():
raise ValueError("previous interrupted package exists; preserve/recover .runtime-data-previous before retrying")
data = package / "data"
if data.exists():
data.rename(previous)
try:
staging.rename(data)
except BaseException:
if previous.exists():
previous.rename(data)
raise
if previous.exists():
shutil.rmtree(previous)
finally:
if staging.exists():
shutil.rmtree(staging)
(package / "bin").mkdir(exist_ok=True)
shutil.copy2(executable, package / "bin/raptor-rust")
dependencies = subprocess.run(["ldd", str(executable)], env=environment,
capture_output=True, text=True, check=False).stdout
if "not found" in dependencies:
raise ValueError("unresolved release dependency:\n" + dependencies)
sdl_libraries = re.findall(r"libSDL2[^ ]* => ([^ ]+)", dependencies)
if not sdl_libraries:
raise ValueError("release binary has no SDL2 dependency; rebuild with the desktop feature")
for library in sdl_libraries:
destination = package / "lib"
destination.mkdir(exist_ok=True)
shutil.copy2(Path(library).resolve(), destination / "libSDL2-2.0.so.0")
copyright_file = Path("/usr/share/doc/libsdl2-2.0-0/copyright")
if copyright_file.is_file():
shutil.copy2(copyright_file, destination / "SDL2-COPYRIGHT.txt")
report = {"format_version": 1, "platform": "Linux x86_64", "catalog_entries": len([row for row in entries if row.strip()]),
"approved_hd_frames": hd_frames, "binary_sha256": digest(package / "bin/raptor-rust"),
"data_files": len(files), "data_bytes": sum((package / "data" / name).stat().st_size for name in files),
"files": {"data/" + name: digest(package / "data" / name) for name in sorted(files)},
"system_dependencies": dependencies.splitlines()}
(package / "runtime-manifest.json").write_text(json.dumps(report, indent=2) + "\n")
subprocess.run([str(package / "run.sh"), "--verify-assets"], cwd="/", check=True)
if args.zip:
archive = args.zip.resolve()
archive.parent.mkdir(parents=True, exist_ok=True)
skipped = {"target", "dist", ".reference", ".local", "derived", "__pycache__", ".git"}
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as output:
for path in iter_archive_files(package, archive, skipped,
required_files=report["files"]):
relative = path.relative_to(package)
output.write(path, str(Path("RustRaptor") / relative))
print(json.dumps({key: value for key, value in report.items() if key not in {"files", "system_dependencies"}}, indent=2))
if __name__ == "__main__":
main()