From 6b1a2629a01c967322d4aa47f11d3560e0e7b4bf Mon Sep 17 00:00:00 2001 From: raptorreplica Date: Sun, 18 Oct 2026 12:16:25 +1100 Subject: [PATCH] Keep manifest-required derived artwork in portable game archives --- .github/workflows/windows.yml | 3 ++ docs/resume-checkpoint.md | 2 + tools/assemble_runtime.py | 17 +++++-- tools/assemble_windows.py | 3 +- tools/test_runtime_archives.py | 82 ++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 tools/test_runtime_archives.py diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index fb154436..fdd4aeff 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -18,6 +18,7 @@ on: - "runtime-windows-manifest.json" - "tools/assemble_windows.py" - "tools/assemble_runtime.py" + - "tools/test_runtime_archives.py" - ".github/workflows/windows.yml" permissions: @@ -71,6 +72,8 @@ jobs: $env:SDL_AUDIODRIVER = "dummy" & "$package\run.cmd" --profile-root "$env:RUNNER_TEMP\bundled-profile" --smoke-frames 32 --preview-mission base:sector_1_wave_1 if ($LASTEXITCODE -ne 0) { throw "Included Windows SDL gameplay failed" } + - name: Test runtime archive payload selection + run: python -m unittest discover -s tools -p test_runtime_archives.py - name: Build and package Windows desktop run: python tools/assemble_windows.py --target x86_64-pc-windows-msvc --sdl2-dir "$env:RUNNER_TEMP\sdl2\SDL2-2.32.10" --zip dist/RustRaptor-windows-x86_64.zip - name: Check displayed boss phases and SD HD shop artwork diff --git a/docs/resume-checkpoint.md b/docs/resume-checkpoint.md index fde34be4..ae17303b 100644 --- a/docs/resume-checkpoint.md +++ b/docs/resume-checkpoint.md @@ -14,6 +14,8 @@ SourceF, both binaries, Mission 2 version 1.0.3 and the nine palette opt-ins rem The assembled data contains **9,504 files / 8,407,923,443 bytes**. The 307 native AGX cinematic/font records remain preserved separately. Iron Coast retains its published retune, Mission 2 version 1.0.3 and 65-test campaign evidence; see [Iron Coast retune evidence](iron-coast-retune.md). This artwork append changes no mission, weapon or shop behavior. +Windows package follow-up: the exact H5500 run at `00304e25` passed 401 tests (26 ignored, zero failed), then rejected the 15 new picture routes because the ZIP filter skipped directories named `derived`. Mod, PCM and Stage II checks after that HD guard did not run. Both ZIP builders now require every runtime-manifest data file to survive archive selection and fail if any required route is missing. A new synthetic ZIP/relocation check covers the failure; a fresh native Windows result remains pending. [Failed native run](https://github.com/Quintine/RustRaptor/actions/runs/39083332442). + # Earlier 5,339-frame checkpoint — retained historical observation # HD continuation checkpoint at 5,339 frames — 17 October 2026 diff --git a/tools/assemble_runtime.py b/tools/assemble_runtime.py index 9a18f53c..396ceb68 100644 --- a/tools/assemble_runtime.py +++ b/tools/assemble_runtime.py @@ -28,9 +28,16 @@ def checked_file(root, relative): return resolved -def iter_archive_files(package, archive, skipped, excluded_files=()): - """Yield the same sorted archive files without walking excluded subtrees.""" +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" @@ -47,6 +54,9 @@ def iter_archive_files(package, archive, skipped, excluded_files=()): 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) @@ -165,7 +175,8 @@ def main(): 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): + 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)) diff --git a/tools/assemble_windows.py b/tools/assemble_windows.py index b1bb7c07..ee07eaee 100644 --- a/tools/assemble_windows.py +++ b/tools/assemble_windows.py @@ -174,7 +174,8 @@ def main(): skipped = {"target", "dist", ".reference", ".local", "derived", "__pycache__", ".git", "lib"} with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as output: for path in iter_archive_files(package, archive, skipped, - excluded_files=(Path("bin/raptor-rust"),)): + excluded_files=(Path("bin/raptor-rust"),), + required_files=data_manifest["files"]): relative = path.relative_to(package) output.write(path, str(Path("RustRaptor") / relative)) print(json.dumps(report, indent=2)) diff --git a/tools/test_runtime_archives.py b/tools/test_runtime_archives.py new file mode 100644 index 00000000..cf46b61b --- /dev/null +++ b/tools/test_runtime_archives.py @@ -0,0 +1,82 @@ +"""Small archive fixtures; no game build or production data copy required.""" +import hashlib +from pathlib import Path +import tempfile +import unittest +import zipfile + +from assemble_runtime import iter_archive_files + + +class RuntimeArchiveTests(unittest.TestCase): + def test_manifest_derived_payload_survives_cache_filter_and_relocation(self): + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) / "package" + payload = "data/hd/jobs/reviewed-family/derived/200a2-derived.png" + required = ["data/INDEX", "data/hd/hd.json", payload] + fixtures = { + "data/INDEX": b"catalog", + "data/hd/hd.json": b"manifest", + payload: b"accepted derived PNG bytes", + "data/hd/jobs/reviewed-family/derived/rejected-preview.png": b"not admitted", + "derived/preview.png": b"private cache", + "target/build-cache": b"build", + ".reference/private": b"private", + "mods/demo/art/source/parent.png": b"authoring reference", + "mods/demo/art/runtime.png": b"mod runtime", + "bin/raptor-rust": b"Linux only", + "bin/raptor-rust.exe": b"Windows", + } + for name, data in fixtures.items(): + path = package / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + archive = Path(directory) / "windows.zip" + skipped = {"target", "dist", ".reference", "derived", "lib"} + selected = list(iter_archive_files( + package, archive, skipped, excluded_files=(Path("bin/raptor-rust"),), + required_files=required)) + names = {path.relative_to(package).as_posix() for path in selected} + self.assertTrue(set(required) <= names) + self.assertEqual(names - set(required), {"mods/demo/art/runtime.png", "bin/raptor-rust.exe"}) + with zipfile.ZipFile(archive, "w") as output: + for path in selected: + output.write(path, "RustRaptor/" + path.relative_to(package).as_posix()) + relocated = Path(directory) / "Portable Windows Ω folder" + with zipfile.ZipFile(archive) as packed: + packed.extractall(relocated) + for name in required: + original = package / name + copied = relocated / "RustRaptor" / name + self.assertEqual(hashlib.sha256(copied.read_bytes()).digest(), + hashlib.sha256(original.read_bytes()).digest()) + + def test_missing_manifest_payload_fails_before_yielding_files(self): + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) + with self.assertRaises(FileNotFoundError): + next(iter_archive_files(package, package / "out.zip", {"derived"}, + required_files=["data/hd/missing.png"])) + + def test_explicit_exclusion_cannot_silently_drop_manifest_payload(self): + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) + path = package / "data/required" + path.parent.mkdir() + path.write_bytes(b"required") + with self.assertRaisesRegex(ValueError, "missing required files"): + list(iter_archive_files(package, package / "out.zip", set(), + excluded_files=(Path("data/required"),), + required_files=["data/required"])) + + def test_manifest_path_cannot_escape_package(self): + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) + for name in ("../outside", str(package / "absolute")): + with self.subTest(name=name), self.assertRaises(ValueError): + list(iter_archive_files(package, package / "out.zip", set(), + required_files=[name])) + + +if __name__ == "__main__": + unittest.main()