diff --git a/make/ci_dev_workshop.py b/make/ci_dev_workshop.py index 7fe9d0f..8515796 100644 --- a/make/ci_dev_workshop.py +++ b/make/ci_dev_workshop.py @@ -14,8 +14,10 @@ import json import os import re import shutil +import struct import subprocess import sys +import time from pathlib import Path from tempfile import TemporaryDirectory from typing import Any @@ -32,6 +34,29 @@ DEFAULT_STAGE_DIR = Path("dist/dev/@braf_dev") METADATA_FILES = ("mod.cpp", "meta.cpp") MOD_ASSET_FIELDS = ("picture", "logo", "logoOver", "logoSmall") IGNORED_DIRS = {".git", "__pycache__"} +CASE_ALIAS_EXTENSIONS = ( + "bisurf", + "csv", + "fsm", + "hpp", + "jpg", + "ogg", + "paa", + "p3d", + "png", + "rvmat", + "rtm", + "sqf", + "txt", + "wss", + "xml", +) +CASE_ALIAS_RE = re.compile( + rb"[A-Za-z0-9_ ./\\-]+\.(" + + b"|".join(ext.encode("ascii") for ext in CASE_ALIAS_EXTENSIONS) + + rb")", + re.IGNORECASE, +) def parse_args() -> argparse.Namespace: @@ -154,8 +179,132 @@ def remove_cached_addon(cache_addons_dir: Path, addon: str) -> None: path.unlink() +def rel_symlink(src: Path, dst: Path) -> None: + target = os.path.relpath(src, start=dst.parent) + dst.symlink_to(target, target_is_directory=src.is_dir()) + + +def create_source_symlink_tree(src: Path, dst: Path) -> dict[str, Path]: + lower_to_source: dict[str, Path] = {} + for path in sorted(src.rglob("*")): + rel = path.relative_to(src) + target = dst / rel + if path.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + if any(part in IGNORED_DIRS for part in rel.parts): + continue + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() or target.is_symlink(): + target.unlink() + rel_symlink(path, target) + lower_to_source[rel.as_posix().lower()] = path + return lower_to_source + + +def extract_case_alias_refs(addon: str, file_path: Path) -> set[str]: + refs: set[str] = set() + try: + data = file_path.read_bytes() + except OSError: + return refs + + addon_prefixes = ( + f"braf/{addon}/", + f"{addon}/", + ) + for match in CASE_ALIAS_RE.finditer(data): + value = match.group(0).decode("latin-1", errors="ignore") + value = value.strip(" \t\r\n\"'()[]{};,") + value = value.replace("\\", "/").lstrip("/") + lower_value = value.lower() + + rel = None + for prefix in addon_prefixes: + index = lower_value.find(prefix) + if index != -1: + rel = value[index + len(prefix) :] + break + if rel is None and "/" in value: + rel = value + elif rel is None: + continue + + rel_path = Path(rel) + if rel_path.is_absolute() or ".." in rel_path.parts: + continue + refs.add(rel_path.as_posix()) + return refs + + +def add_case_aliases(addon: str, src: Path, dst: Path, lower_to_source: dict[str, Path]) -> None: + wanted_refs = {rel.lower(): rel for rel in lower_to_source} + for file_path in sorted(path for path in src.rglob("*") if path.is_file()): + for ref in extract_case_alias_refs(addon, file_path): + wanted_refs.setdefault(ref.lower(), ref) + + for lower_ref, requested_ref in sorted(wanted_refs.items()): + source = lower_to_source.get(lower_ref) + if source is None: + continue + alias = dst / requested_ref + if alias.exists() or alias.is_symlink(): + continue + alias.parent.mkdir(parents=True, exist_ok=True) + rel_symlink(source, alias) + + +def raw_pbo_files(source: Path) -> list[tuple[str, Path, int]]: + files: list[tuple[str, Path, int]] = [] + for file_path in sorted( + (path for path in source.rglob("*") if path.is_file()), + key=lambda path: path.relative_to(source).as_posix().lower(), + ): + rel = file_path.relative_to(source) + if any(part in IGNORED_DIRS for part in rel.parts): + continue + files.append((rel.as_posix().replace("/", "\\"), file_path, file_path.stat().st_size)) + return files + + +def write_raw_pbo(addon: str, source: Path, output_path: Path) -> None: + files = raw_pbo_files(source) + output_path.parent.mkdir(parents=True, exist_ok=True) + timestamp = int(time.time()) + sha = hashlib.sha1() + + with output_path.open("wb") as handle: + def write(data: bytes) -> None: + handle.write(data) + sha.update(data) + + write(b"\0") + write(struct.pack(" Path: - """Create a P:/braf-like source root using symlinks. + """Create a P:/braf-like source root with case aliases for Linux Mikero. The historic local build path was P:/braf/. Keeping the parent folder named "braf" avoids changing Mikero's inferred addon prefix in CI. @@ -174,14 +323,14 @@ def prepare_mikero_source_root(root: Path, cache_dir: Path, addons: list[str]) - for addon in addons: dst = source_root / addon src = root / addon - if dst.is_symlink() and dst.resolve() == src.resolve(): - continue if dst.exists() or dst.is_symlink(): if dst.is_dir() and not dst.is_symlink(): shutil.rmtree(dst) else: dst.unlink() - dst.symlink_to(src, target_is_directory=True) + dst.mkdir(parents=True) + lower_to_source = create_source_symlink_tree(src, dst) + add_case_aliases(addon, src, dst, lower_to_source) return source_root @@ -193,7 +342,6 @@ def run_pboproject(addon: str, source_root: Path, output_dir: Path) -> None: output_dir.mkdir(parents=True, exist_ok=True) pboproject = shutil.which("pboproject") or shutil.which("pboProject") - makepbo = shutil.which("makepbo") or shutil.which("MakePbo") if pboproject: cmd = [ @@ -203,16 +351,15 @@ def run_pboproject(addon: str, source_root: Path, output_dir: Path) -> None: f"+M={output_dir}", "+O" if addon in obfuschatedList else "-O", ] - elif makepbo: - cmd = [ - makepbo, - "-P", - f"+@=braf\\{addon}", - str(addon_source), - str(output_dir / f"{addon}.pbo"), - ] else: - raise RuntimeError("Neither pboProject nor makepbo was found in PATH") + source = repo_root() / addon + fallback = output_dir / f"{addon}.pbo" + print( + "Mikero pboProject is not available; writing raw DEV PBO " + f"for {addon} at {fallback}" + ) + write_raw_pbo(addon, source, fallback) + return print(f"::group::Building {addon}") print(" ".join(cmd)) @@ -320,6 +467,8 @@ def main() -> int: source_root = Path(args.source_root).resolve() elif args.dry_run: source_root = (cache_dir / "mikero-source" / "braf").resolve() + elif not (shutil.which("pboproject") or shutil.which("pboProject")): + source_root = root else: source_root_addons = requested_addons if requested_addons is not None else addons source_root = prepare_mikero_source_root(root, cache_dir, source_root_addons).resolve()