183 lines
10 KiB
Python
183 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Review a proposed HD family with the exact already-built Rust loader.
|
|
|
|
Creates an isolated temporary pack with hard-linked raw artwork. It never edits
|
|
the active manifest or generated pixels. Build the game library before use.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
FRAME_KEYS = {"frame", "file", "logical_width", "logical_height", "origin", "source_sha256", "crop"}
|
|
|
|
|
|
def proposal_frames(proposal, expected=None):
|
|
"""Check source-bound target membership; artistic approval remains separate."""
|
|
rows = {}
|
|
for sequence in proposal.get("sequences", []):
|
|
base = sequence["base_asset"]
|
|
if isinstance(base, str):
|
|
base = int(base, 16)
|
|
if not 1 <= sequence["frame_count"] <= 512:
|
|
raise ValueError("invalid sequence frame count")
|
|
for frame in sequence["frames"]:
|
|
if set(frame) - FRAME_KEYS:
|
|
raise ValueError("unknown manifest frame fields")
|
|
if not 0 <= frame["frame"] < sequence["frame_count"]:
|
|
raise ValueError("frame outside sequence")
|
|
identity = f"{base + frame['frame']:05x}"
|
|
if identity in rows:
|
|
raise ValueError("duplicate candidate ID")
|
|
rows[identity] = frame
|
|
if not rows:
|
|
raise ValueError("candidate needs manifest-compatible sequences")
|
|
supplied = {str(row["asset"]).lower().removeprefix("0x").zfill(5): row for row in proposal.get("targets", [])}
|
|
if supplied and set(supplied) != set(rows):
|
|
raise ValueError("candidate targets and sequences differ")
|
|
for identity, frame in rows.items():
|
|
if supplied and any(supplied[identity].get(key) != value for key, value in frame.items()):
|
|
raise ValueError(f"target and sequence metadata differ for {identity}")
|
|
if expected is not None:
|
|
if identity not in expected:
|
|
raise ValueError(f"candidate claims foreign ID {identity}")
|
|
source = expected[identity]
|
|
if frame["source_sha256"] != source["source_sha256"]:
|
|
raise ValueError(f"candidate source changed for {identity}")
|
|
if [frame["logical_width"], frame["logical_height"]] != source["logical_canvas"]:
|
|
raise ValueError(f"candidate logical canvas changed for {identity}")
|
|
if frame["origin"] != [0, 0]:
|
|
raise ValueError(f"candidate anchor changed for {identity}")
|
|
relative = Path(frame["file"])
|
|
if relative.is_absolute() or ".." in relative.parts:
|
|
raise ValueError("artwork must remain pack-relative")
|
|
if expected is not None and proposal.get("complete_family") and set(rows) != set(expected):
|
|
raise ValueError("complete-family claim omits source IDs")
|
|
return rows
|
|
|
|
|
|
def binding_digest(rows):
|
|
"""Hash only renderer-bound fields, separately from artist review notes."""
|
|
return hashlib.sha256(json.dumps(rows, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
|
|
|
|
def main():
|
|
project = Path(__file__).resolve().parents[1]
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--candidate", type=Path, required=True)
|
|
parser.add_argument("--data", type=Path, default=project.parent / "data")
|
|
parser.add_argument("--output", type=Path)
|
|
parser.add_argument("--profile", choices=["auto", "debug", "release"], default="auto")
|
|
parser.add_argument("--sheet-rows", type=int, default=16, help="Maximum frames per contact-sheet page; review every page")
|
|
args = parser.parse_args()
|
|
if not 1 <= args.sheet_rows <= 64:
|
|
parser.error("sheet-rows must be 1..64")
|
|
candidate_at_start = args.candidate.read_bytes()
|
|
proposal = json.loads(candidate_at_start)
|
|
job_path = args.candidate.parent / "job.json"
|
|
expected = None
|
|
if job_path.is_file():
|
|
job = json.loads(job_path.read_text())
|
|
if proposal.get("job_id") != job["job_id"]:
|
|
raise ValueError("candidate job identity differs")
|
|
expected = {target["asset"]: target for target in job["targets"]}
|
|
rows = proposal_frames(proposal, expected)
|
|
bindings_at_start = binding_digest(rows)
|
|
static_cards = any(int(identity, 16) in [0x1002b, 0x1002d, 0x1002f] for identity in rows)
|
|
manifest = {"format_version": 1, "name": "Isolated candidate review", "description": "Unregistered diagnostic pack", "sequences": proposal["sequences"]}
|
|
profiles = ["debug", "release"] if args.profile == "auto" else [args.profile]
|
|
libraries = [library for profile in profiles for library in (project / "target" / profile / "deps").glob("libraptor_replica-*.rlib")]
|
|
if not libraries:
|
|
raise ValueError("build the Rust game library before candidate review")
|
|
library = max(libraries, key=lambda path: path.stat().st_mtime_ns)
|
|
library_at_start = library.stat()
|
|
dependency_directory = library.parent
|
|
serde = max(dependency_directory.glob("libserde_json-*.rlib"), key=lambda path: path.stat().st_mtime_ns)
|
|
output = (args.output or args.candidate.parent / "runtime-review").resolve()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
pack_root = (args.data / "hd").resolve()
|
|
artwork_hashes = {}
|
|
with tempfile.TemporaryDirectory(prefix="isolated-pack-", dir=output) as temporary:
|
|
isolated = Path(temporary)
|
|
for frame in rows.values():
|
|
source = (pack_root / frame["file"]).resolve()
|
|
if not source.is_relative_to(pack_root):
|
|
raise ValueError("artwork escapes real pack root")
|
|
artwork_hashes[frame["file"]] = hashlib.sha256(source.read_bytes()).hexdigest()
|
|
destination = isolated / frame["file"]
|
|
if destination.exists():
|
|
continue
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
os.link(source, destination)
|
|
except OSError:
|
|
shutil.copyfile(source, destination)
|
|
(isolated / "hd.json").write_text(json.dumps(manifest))
|
|
binary = isolated / "review-bin"
|
|
compiler_args = ["rustc", "--edition=2021", str(project / "tools/hd_candidate_review.rs"), "--extern", f"raptor_replica={library}", "--extern", f"serde_json={serde}", "-L", f"dependency={dependency_directory}", "-L", "native=" + str(Path.home() / ".local/lib"), "-o", str(binary)]
|
|
if static_cards:
|
|
compiler_args.extend(["--cfg", "static_card_api"])
|
|
subprocess.run(compiler_args, check=True)
|
|
environment = os.environ.copy()
|
|
environment["LD_LIBRARY_PATH"] = str(Path.home() / ".local/lib") + os.pathsep + environment.get("LD_LIBRARY_PATH", "")
|
|
subprocess.run([str(binary), str(args.data.resolve()), str(isolated), str(output)], check=True, env=environment)
|
|
for relative, digest in artwork_hashes.items():
|
|
if hashlib.sha256((pack_root / relative).read_bytes()).hexdigest() != digest:
|
|
raise ValueError("artwork changed during runtime review")
|
|
library_at_finish = library.stat()
|
|
if (library_at_start.st_ino, library_at_start.st_size, library_at_start.st_mtime_ns) != (library_at_finish.st_ino, library_at_finish.st_size, library_at_finish.st_mtime_ns):
|
|
raise ValueError("Rust library changed during runtime review; rebuild and rerun")
|
|
qa_path = output / "runtime-qa.json"
|
|
qa = json.loads(qa_path.read_text())
|
|
candidate_at_finish = args.candidate.read_bytes()
|
|
if binding_digest(proposal_frames(json.loads(candidate_at_finish), expected)) != bindings_at_start:
|
|
raise ValueError("candidate renderer bindings changed during runtime review")
|
|
qa["library"] = str(library.relative_to(project))
|
|
qa["library_modified_ns"] = library_at_start.st_mtime_ns
|
|
qa["candidate_sha256"] = hashlib.sha256(candidate_at_finish).hexdigest()
|
|
qa["candidate_sha256_at_start"] = hashlib.sha256(candidate_at_start).hexdigest()
|
|
qa["candidate_notes_changed_during_review"] = candidate_at_start != candidate_at_finish
|
|
qa["frame_bindings_sha256"] = bindings_at_start
|
|
qa["artwork_sha256"] = artwork_hashes
|
|
qa["static_card_palette_mode"] = static_cards
|
|
qa["source_newer_than_library"] = any((project / "src" / name).stat().st_mtime_ns > library_at_start.st_mtime_ns for name in ["assets.rs", "hd.rs"])
|
|
qa["any_game_source_newer_than_library"] = any(path.stat().st_mtime_ns > library_at_start.st_mtime_ns for path in (project / "src").glob("*.rs"))
|
|
qa_path.write_text(json.dumps(qa, indent=2) + "\n")
|
|
identities = sorted(rows)
|
|
pages = []
|
|
# These are tool-produced diagnostics, never input art. Remove stale pages
|
|
# so a former shorter proposal cannot be mistaken for this complete review.
|
|
for obsolete in output.glob("runtime-contact-sheet*.png"):
|
|
obsolete.unlink()
|
|
for start in range(0, len(identities), args.sheet_rows):
|
|
page = identities[start:start + args.sheet_rows]
|
|
cell_width = max(rows[identity]["logical_width"] for identity in page) * 4 + 24
|
|
row_height = max(rows[identity]["logical_height"] for identity in page) * 4 + 42
|
|
sheet = Image.new("RGB", (cell_width * 3, row_height * len(page)), (20, 28, 38))
|
|
draw = ImageDraw.Draw(sheet)
|
|
for row, identity in enumerate(page):
|
|
for column, (suffix, label) in enumerate([("original", "Original"), ("hd-1", "Rust 1x"), ("hd-4", "Rust 4x")]):
|
|
with Image.open(output / f"{identity}-{suffix}.ppm") as source:
|
|
sprite = source.convert("RGB")
|
|
if suffix != "hd-4":
|
|
sprite = sprite.resize((sprite.width * 4, sprite.height * 4), Image.Resampling.NEAREST)
|
|
x, y = column * cell_width + 12, row * row_height + 36
|
|
draw.text((x, row * row_height + 4), identity, fill=(225, 225, 230))
|
|
draw.text((x, row * row_height + 16), label, fill=(225, 225, 230))
|
|
sheet.paste(sprite, (x, y))
|
|
name = "runtime-contact-sheet.png" if len(identities) <= args.sheet_rows else f"runtime-contact-sheet-{len(pages) + 1:03}.png"
|
|
sheet.save(output / name)
|
|
pages.append({"file": name, "assets": page})
|
|
(output / "runtime-contact-sheets.json").write_text(json.dumps({"review_every_page": True, "pages": pages}, indent=2) + "\n")
|
|
print(json.dumps({"output": str(output), "frames": len(rows), "sheet_pages": len(pages), "stale_library": qa["source_newer_than_library"]}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|