#!/usr/bin/env python3 """Walk a client directory and emit a release manifest. Produces canonical-form JSON matching docs/update-manifest.md. Does not sign — pair with a separate signer that reads the manifest bytes verbatim and emits a detached Ed25519 signature. Usage: make-manifest.py --source /path/to/client --version 2026.04.14-1 \\ [--previous 2026.04.13-3] [--notes "bugfixes"] \\ [--launcher Metin2Launcher.exe] [--out manifest.json] The launcher path is treated specially: its entry appears at the top level under "launcher", not inside "files". Defaults to "Metin2Launcher.exe"; if that file does not exist in the source tree, the script refuses to run. """ from __future__ import annotations import argparse import hashlib import json import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path # Files and directories that must never appear in a release manifest. # These are runtime artifacts, build outputs, or developer state. EXCLUDE_DIRS = { ".git", ".vs", ".updates", "log", "__pycache__", } EXCLUDE_FILES = { ".gitignore", ".gitattributes", "desktop.ini", "Thumbs.db", ".DS_Store", } EXCLUDE_SUFFIXES = { ".pdb", # debug symbols ".ilk", # MSVC incremental link ".old", # rollback copies written by the launcher ".log", # runtime logs, not release content ".dxvk-cache", # DXVK shader cache, per-machine ".swp", ".tmp", } @dataclass class FileEntry: path: str sha256: str size: int platform: str = "all" required: bool = True executable: bool = False def to_dict(self) -> dict: out = { "path": self.path, "sha256": self.sha256, "size": self.size, } if self.platform != "all": out["platform"] = self.platform if not self.required: out["required"] = False if self.executable: out["executable"] = True return out def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def should_skip(rel_path: Path) -> bool: for part in rel_path.parts: if part in EXCLUDE_DIRS: return True if rel_path.name in EXCLUDE_FILES: return True if rel_path.suffix in EXCLUDE_SUFFIXES: return True return False def classify_platform(rel_path: Path) -> str: """Very simple platform inference. Extend as native Linux build lands.""" suffix = rel_path.suffix.lower() if suffix in {".exe", ".dll"}: return "windows" return "all" def walk_client(source: Path, launcher_rel: Path) -> tuple[FileEntry, list[FileEntry]]: launcher_entry: FileEntry | None = None files: list[FileEntry] = [] for path in sorted(source.rglob("*")): if not path.is_file(): continue rel = path.relative_to(source) if should_skip(rel): continue entry = FileEntry( path=rel.as_posix(), sha256=sha256_file(path), size=path.stat().st_size, platform=classify_platform(rel), ) if rel == launcher_rel: launcher_entry = entry else: files.append(entry) if launcher_entry is None: raise SystemExit( f"error: launcher file {launcher_rel} not found under {source}. " f"pass --launcher if the launcher is named differently, or create it." ) files.sort(key=lambda e: e.path) return launcher_entry, files def build_manifest( version: str, created_at: str, previous: str | None, notes: str | None, min_launcher_version: str | None, launcher: FileEntry, files: list[FileEntry], ) -> dict: """Assemble the manifest dict in canonical key order.""" manifest: dict = {"version": version, "created_at": created_at} if previous is not None: manifest["previous"] = previous if notes is not None: manifest["notes"] = notes if min_launcher_version is not None: manifest["min_launcher_version"] = min_launcher_version manifest["launcher"] = launcher.to_dict() manifest["files"] = [f.to_dict() for f in files] return manifest def canonical_json(manifest: dict) -> bytes: text = json.dumps(manifest, indent=2, ensure_ascii=False) return (text + "\n").encode("utf-8") def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--source", required=True, type=Path, help="Path to the client root directory") parser.add_argument("--version", required=True, help="Release version, e.g. 2026.04.14-1") parser.add_argument("--previous", help="Previous release version, if any") parser.add_argument("--notes", help="Release notes (Markdown allowed)") parser.add_argument("--min-launcher-version", help="Minimum launcher version that can apply this manifest") parser.add_argument("--launcher", default="Metin2Launcher.exe", help="Launcher filename relative to source (default: Metin2Launcher.exe)") parser.add_argument("--out", type=Path, default=Path("manifest.json"), help="Output file path (default: ./manifest.json)") parser.add_argument("--created-at", help="Override created_at timestamp (default: now, UTC). Useful for reproducible test runs.") args = parser.parse_args() source: Path = args.source.resolve() if not source.is_dir(): print(f"error: {source} is not a directory", file=sys.stderr) return 1 created_at = args.created_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") launcher_rel = Path(args.launcher) launcher, files = walk_client(source, launcher_rel) manifest = build_manifest( version=args.version, created_at=created_at, previous=args.previous, notes=args.notes, min_launcher_version=args.min_launcher_version, launcher=launcher, files=files, ) args.out.write_bytes(canonical_json(manifest)) total_size = launcher.size + sum(f.size for f in files) print( f"manifest: {args.out} " f"files: {len(files) + 1} " f"total: {total_size / (1024 * 1024):.1f} MiB", file=sys.stderr, ) return 0 if __name__ == "__main__": raise SystemExit(main())