Files
Alex Vanderveen 874df9b5bb release: ship bundled, compiled games from committed generated/ C
Retire the setup-host release model (CI wiped generated/ and shipped
emitters + sources + a wizard so every player regenerated and rebuilt the
title). Titles now commit generated/ and CI ships the compiled game.

- docs/ci/templates/game-release.yml replaces setup-release.yml: gate on
  committed game C (tools/ci/check_generated.sh), emit the OpenBIOS backend
  in CI (tools/ci/generate_openbios.sh), configure with
  PSXRECOMP_REQUIRE_GAME_C=ON and assert the full path was taken, package
  with tools/package_game_release.sh, verify the zip is a game not a kit.
- tools/package_game_release.sh: exe, assets, bundled OpenBIOS, configs, mod
  catalog + overlay toolchain via release_stage.py, notices; refuses sources,
  emitters, generated C, BIOS dumps. clear_generated.sh removed;
  package_setup_host.sh marked retired.
- Scaffold: gitignore templates track generated/, package_release.sh.in
  wrapper, setup_project.{sh,ps1} commit generated/ after Generate.
- Project Studio: audit flags ignored/untracked generated/, retail BIOS C
  and setup-host workflows; merge_gitignore un-ignores generated/; ops emit
  the bundled packager + workflow; generate_ci uses the new template.
- Docs: docs/ci/BUNDLED_RELEASES.md replaces HOST_ONLY_RELEASES.md.
- Tests: tools/tests/test_bundled_release_layout.py; fix pre-existing
  false positives in the packager-surface and overlay-static tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 17:11:14 -04:00

1112 lines
39 KiB
Python

"""Audit an existing title repo against the New Project Layout (bundled releases)."""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from .models import (
AuditReport,
CheckResult,
CheckStatus,
LayoutClass,
Severity,
)
from .naming import boot_exe_from_game_toml, infer_project_name
from .readme_metrics import (
boxart_png_present,
readme_has_boxart,
readme_has_launcher,
readme_has_metrics,
readme_has_raid,
)
def _read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
def _gitmodules_paths(root: Path) -> dict[str, str]:
"""path → url from .gitmodules."""
gm = root / ".gitmodules"
if not gm.is_file():
return {}
text = _read(gm)
out: dict[str, str] = {}
path = url = None
for line in text.splitlines():
s = line.strip()
if s.startswith("[submodule"):
if path and url:
out[path] = url
path = url = None
continue
if s.startswith("path"):
_, _, v = s.partition("=")
path = v.strip()
elif s.startswith("url"):
_, _, v = s.partition("=")
url = v.strip()
if path and url:
out[path] = url
return out
def _is_real_psxrecomp_tree(path: Path) -> bool:
return (path / "runtime" / "runtime.cmake").is_file()
def _git_checkout_ok(path: Path) -> bool:
"""True when ``git -C path rev-parse`` sees a live work tree."""
if not path.is_dir():
return False
try:
proc = subprocess.run(
["git", "-C", str(path), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
check=False,
)
except OSError:
return False
return proc.returncode == 0 and proc.stdout.strip() == "true"
def diagnose_psxrecomp_checkout(root: Path) -> str | None:
"""Return a human reason when ``psxrecomp/`` is present but not a usable git checkout.
Common after consolidating away ``psxrecomp-v4``: ``psxrecomp/.git`` still
points at ``../.git/modules/psxrecomp-v4`` (deleted), so Studio module ops
report ``checkout missing`` even though ``runtime.cmake`` is on disk.
"""
dest = root / "psxrecomp"
if not dest.is_dir():
return None
if not _is_real_psxrecomp_tree(dest):
return None
if _git_checkout_ok(dest):
return None
git_file = dest / ".git"
if git_file.is_file():
text = _read(git_file).strip()
if text.lower().startswith("gitdir:"):
rel = text.split(":", 1)[1].strip()
target = (dest / rel).resolve() if rel else None
if target is not None and not target.exists():
return (
f"psxrecomp/.git points at missing gitdir ({rel}) — "
"broken submodule metadata (often leftover psxrecomp-v4)."
)
return f"psxrecomp/.git gitdir is unusable ({rel or text})."
if git_file.is_dir():
return "psxrecomp/.git exists but git rev-parse fails."
return (
"psxrecomp/ has runtime.cmake but is not a git checkout "
"(absorbed into the parent tree or missing .git)."
)
def _cmake_has(text: str, needle: str) -> bool:
return needle in text
# Trees the packager stages on its own (framework submodules, build output,
# and the app-icon dir it installs) — never project files.
_PACKAGER_IMPLICIT = {
"psxrecomp", "recomp-ui", "gbarecomp", "snesrecomp", "ndsrecomp",
"assets", "generated", "build", "variants", "third_party",
}
def _cmake_source_refs(cmake: str) -> tuple[set[str], set[str]]:
"""Paths CMakeLists.txt reads from the project root.
Returns (referenced, guarded). ``guarded`` are those used only inside
``if(EXISTS ...)``, which are optional by construction. Globs and paths
built from cmake variables cannot be resolved statically and are dropped.
"""
refs: set[str] = set()
for m in re.finditer(r"\$\{CMAKE_CURRENT_SOURCE_DIR\}/([^\"\s)]+)", cmake):
rel = m.group(1).rstrip('"')
if not rel or any(c in rel for c in "*?$"):
continue
refs.add(rel)
guarded = set()
for m in re.finditer(
r"if\(EXISTS\s+\"\$\{CMAKE_CURRENT_SOURCE_DIR\}/([^\"]+)\"", cmake
):
guarded.add(m.group(1))
return refs, guarded
# The bundled-release wrapper and the retired setup-host one. Audits accept
# either as "a packager is present" but only the first as current.
PACKAGER_WRAPPER = "package_release.sh"
LEGACY_PACKAGER_WRAPPER = "package_setup_release.sh"
def packager_wrapper(root: Path) -> Path | None:
"""The title's packager wrapper: the bundled one, else the retired one."""
for name in (PACKAGER_WRAPPER, LEGACY_PACKAGER_WRAPPER):
p = root / "scripts" / name
if p.is_file():
return p
return None
def packager_is_bundled(packager: Path) -> bool:
"""True when the wrapper calls the shared bundled packager (no source allowlist)."""
return "package_game_release.sh" in _read(packager)
def packager_staged_paths(packager_text: str) -> set[str]:
"""Top-level paths the packager passes as --project-file / --project-dir."""
out: set[str] = set()
# Two call shapes appear: the template's trailing-backslash argument list
# ("--project-dir seeds \") and the conditional array form
# ("EXTRA_PROJECT+=(--project-dir mods)"), whose closing paren is not part
# of the path.
for m in re.finditer(r"--project-(?:file|dir)\s+([^\s\\)]+)", packager_text):
out.add(m.group(1).strip('"\'').split("/")[0])
return out
def packager_missing_paths(root: Path) -> list[str]:
"""Paths CMakeLists.txt requires that the release zip would not contain.
The packager allowlist has to track CMakeLists.txt by hand, and when it
drifts the zip still builds — the failure only lands on a user's machine,
at cmake configure ("Cannot find source file"). Same rule the packager's
own pre-zip gate applies, so the two cannot disagree.
"""
cmake_path = root / "CMakeLists.txt"
packager = packager_wrapper(root)
if not cmake_path.is_file() or packager is None or packager_is_bundled(packager):
return []
refs, guarded = _cmake_source_refs(_read(cmake_path))
staged = packager_staged_paths(_read(packager))
missing: set[str] = set()
for rel in refs - guarded:
top = rel.split("/")[0]
if top in _PACKAGER_IMPLICIT or top in staged:
continue
if not (root / rel).exists():
continue # dangling in the repo too — a different problem
missing.add(top)
return sorted(missing)
def packager_missing_optional_paths(root: Path) -> list[str]:
"""Present in the repo, referenced under if(EXISTS ...), not in the zip.
These do not break the build — cmake skips the guarded block — but the
feature quietly does not ship. mods/preloaded/packages is the case that
matters: the activation plugin is compiled in and then has no package to
select, so the mod builds and never enables.
"""
cmake_path = root / "CMakeLists.txt"
packager = packager_wrapper(root)
if not cmake_path.is_file() or packager is None or packager_is_bundled(packager):
return []
refs, guarded = _cmake_source_refs(_read(cmake_path))
staged = packager_staged_paths(_read(packager))
out: set[str] = set()
for rel in guarded & refs:
top = rel.split("/")[0]
if top in _PACKAGER_IMPLICIT or top in staged:
continue
if (root / rel).exists():
out.add(top)
return sorted(out)
def audit_project(root: Path) -> AuditReport:
root = root.resolve()
name = infer_project_name(root)
boot = boot_exe_from_game_toml(root / "game.toml")
checks: list[CheckResult] = []
notes: list[str] = []
gm = _gitmodules_paths(root)
has_v4 = "psxrecomp-v4" in gm or (root / "psxrecomp-v4").is_dir()
has_psx = "psxrecomp" in gm or _is_real_psxrecomp_tree(root / "psxrecomp")
stub_psx = (root / "psxrecomp").is_dir() and not _is_real_psxrecomp_tree(root / "psxrecomp")
has_ui = "recomp-ui" in gm or (root / "recomp-ui").is_dir()
cmake_path = root / "CMakeLists.txt"
cmake = _read(cmake_path) if cmake_path.is_file() else ""
# --- Submodule path ---
if has_v4 and not has_psx:
checks.append(
CheckResult(
id="submodule_psxrecomp",
title="Root psxrecomp/ submodule",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="Found psxrecomp-v4; rename to psxrecomp/ for New Project Layout.",
fix_op="rename_psxrecomp_submodule",
)
)
elif has_v4 and has_psx:
checks.append(
CheckResult(
id="submodule_psxrecomp",
title="Root psxrecomp/ submodule",
status=CheckStatus.WARN,
severity=Severity.REQUIRED,
detail=(
"Both psxrecomp/ and psxrecomp-v4 present — keep psxrecomp/ "
"and delete the legacy psxrecomp-v4 tree."
),
fix_op="rename_psxrecomp_submodule",
)
)
elif stub_psx and has_v4:
checks.append(
CheckResult(
id="submodule_psxrecomp",
title="Root psxrecomp/ submodule",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="psxrecomp/ is a stub (no runtime.cmake); real tree is psxrecomp-v4.",
fix_op="rename_psxrecomp_submodule",
)
)
elif has_psx:
broken = diagnose_psxrecomp_checkout(root)
if broken:
checks.append(
CheckResult(
id="submodule_psxrecomp",
title="Root psxrecomp/ submodule",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=broken + " Repair re-clones as a real submodule.",
fix_op="repair_psxrecomp_submodule",
)
)
else:
checks.append(
CheckResult(
id="submodule_psxrecomp",
title="Root psxrecomp/ submodule",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail="psxrecomp/ present with runtime.cmake.",
)
)
else:
checks.append(
CheckResult(
id="submodule_psxrecomp",
title="Root psxrecomp/ submodule",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="No psxrecomp submodule found.",
fix_op="ensure_psxrecomp_submodule",
)
)
if has_ui:
checks.append(
CheckResult(
id="submodule_recomp_ui",
title="recomp-ui submodule",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail="recomp-ui/ present (required for setup-host wizard).",
)
)
else:
checks.append(
CheckResult(
id="submodule_recomp_ui",
title="recomp-ui submodule",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="Missing recomp-ui/ — setup-host wizard needs it.",
fix_op="ensure_recomp_ui_submodule",
)
)
# --- CMake API ---
if not cmake_path.is_file():
checks.append(
CheckResult(
id="cmake_game_runtime",
title="psxrecomp_add_game_runtime",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="No CMakeLists.txt.",
fix_op="rewrite_cmake_setup_host",
)
)
elif _cmake_has(cmake, "psxrecomp_add_game_runtime"):
detail = "Uses psxrecomp_add_game_runtime."
status = CheckStatus.PASS
fix = None
if "psxrecomp-v4" in cmake:
status = CheckStatus.WARN
detail += " Still references psxrecomp-v4 path."
fix = "rewrite_cmake_setup_host"
checks.append(
CheckResult(
id="cmake_game_runtime",
title="psxrecomp_add_game_runtime",
status=status,
severity=Severity.REQUIRED,
detail=detail,
fix_op=fix,
)
)
elif _cmake_has(cmake, "psxrecomp_add_runtime_target"):
checks.append(
CheckResult(
id="cmake_game_runtime",
title="psxrecomp_add_game_runtime",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="Legacy psxrecomp_add_runtime_target — rewrite to setup-host helper.",
fix_op="rewrite_cmake_setup_host",
)
)
else:
checks.append(
CheckResult(
id="cmake_game_runtime",
title="psxrecomp_add_game_runtime",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="CMakeLists.txt does not call a known runtime helper.",
fix_op="rewrite_cmake_setup_host",
)
)
# Split-gen / GEN_FULL_GLOB
if _cmake_has(cmake, "GEN_FULL_GLOB") or re.search(
r"full_\*\.c|full_\[0-9\]", cmake
):
checks.append(
CheckResult(
id="split_gen",
title="Split-gen generated shards",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail="CMake already globs *_full_*.c / GEN_FULL_GLOB.",
)
)
elif cmake:
checks.append(
CheckResult(
id="split_gen",
title="Split-gen generated shards",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="No GEN_FULL_GLOB / shard glob — regenerate will need shard support.",
fix_op="rewrite_cmake_setup_host",
)
)
# Wizard
wizard_ok = (
_cmake_has(cmake, "PSX_SETUP_WIZARD")
or _cmake_has(cmake, "ENABLE_SETUP_WIZARD")
)
if wizard_ok:
checks.append(
CheckResult(
id="setup_wizard",
title="Setup wizard flags",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail="PSX_SETUP_WIZARD / ENABLE_SETUP_WIZARD present.",
)
)
else:
checks.append(
CheckResult(
id="setup_wizard",
title="Setup wizard flags",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="Missing wizard flags — setup-host CI will not open first-run UI.",
fix_op="rewrite_cmake_setup_host",
)
)
# codegen_setup
if (root / "codegen_setup.c").is_file() and (root / "codegen_setup.h").is_file():
cg = _read(root / "codegen_setup.c")
if "psx_game_codegen_forward_if_built" in cg:
checks.append(
CheckResult(
id="codegen_setup",
title="codegen_setup.c / .h",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail="codegen_setup present with forward_if_built.",
)
)
else:
checks.append(
CheckResult(
id="codegen_setup",
title="codegen_setup.c / .h",
status=CheckStatus.WARN,
severity=Severity.REQUIRED,
detail="codegen_setup.c missing psx_game_codegen_forward_if_built.",
fix_op="emit_codegen_setup",
)
)
else:
checks.append(
CheckResult(
id="codegen_setup",
title="codegen_setup.c / .h",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="Missing thin codegen_setup sources for setup-host.",
fix_op="emit_codegen_setup",
)
)
# Packager
packager = packager_wrapper(root)
if packager is not None and packager_is_bundled(packager):
checks.append(
CheckResult(
id="setup_host_packager",
title=f"scripts/{PACKAGER_WRAPPER}",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail="Bundled-release packager wrapper present (ships the compiled game).",
)
)
elif packager is not None:
checks.append(
CheckResult(
id="setup_host_packager",
title=f"scripts/{PACKAGER_WRAPPER}",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
f"Only the retired setup-host wrapper scripts/{packager.name} is present; "
"releases now ship the compiled game. Re-emit the packager (force)."
),
fix_op="emit_packager",
)
)
# Present is not the same as current: the allowlist inside it has to
# track CMakeLists.txt, and when it drifts the zip ships without a
# source the build needs.
missing_pkg = packager_missing_paths(root)
if missing_pkg:
checks.append(
CheckResult(
id="packager_stages_sources",
title="Packager stages everything CMakeLists.txt needs",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
"Release zip would omit "
+ ", ".join(missing_pkg + [])
+ " — CMakeLists.txt references them, so cmake configure "
"fails on the user's machine with \"Cannot find source file\"."
),
fix_op="sync_packager_project_dirs",
)
)
else:
checks.append(
CheckResult(
id="packager_stages_sources",
title="Packager stages everything CMakeLists.txt needs",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail="Every path CMakeLists.txt requires is in the zip.",
)
)
# Guarded paths do not break the build, but the feature ships dead.
optional_pkg = packager_missing_optional_paths(root)
if optional_pkg:
checks.append(
CheckResult(
id="packager_stages_optional",
title="Packager stages optional runtime data",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail=(
"Release zip would omit "
+ ", ".join(optional_pkg)
+ " — present here and used by CMakeLists.txt under "
"if(EXISTS ...), so the build succeeds and the feature "
"(e.g. preloaded mods) never activates for users."
),
fix_op="sync_packager_project_dirs",
)
)
else:
checks.append(
CheckResult(
id="setup_host_packager",
title=f"scripts/{PACKAGER_WRAPPER}",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="Missing bundled-release packager wrapper (required).",
fix_op="emit_packager",
)
)
# CI
wf = root / ".github" / "workflows" / "release.yml"
if wf.is_file():
wtext = _read(wf)
if "YOUR_ZIP_PREFIX" in wtext or "YOUR_GAME_TITLE" in wtext:
checks.append(
CheckResult(
id="ci_release",
title="Bundled release CI",
status=CheckStatus.WARN,
severity=Severity.REQUIRED,
detail="release.yml still has YOUR_* placeholders.",
fix_op="emit_ci_workflow",
)
)
elif "clear_generated.sh" in wtext or "FORCE_SETUP_HOST=ON" in wtext:
checks.append(
CheckResult(
id="ci_release",
title="Bundled release CI",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
"release.yml is the retired setup-host workflow (wipes generated/ and "
"ships an uncompiled kit). Re-emit it (force) to ship the compiled game."
),
fix_op="emit_ci_workflow",
)
)
else:
checks.append(
CheckResult(
id="ci_release",
title="Bundled release CI",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail=".github/workflows/release.yml present.",
)
)
else:
checks.append(
CheckResult(
id="ci_release",
title="Bundled release CI",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail="Missing release.yml workflow.",
fix_op="emit_ci_workflow",
)
)
# Hand-forked per-title packagers — warn only. The shared bundled packager
# (scripts/package_release.sh -> psxrecomp/tools/package_game_release.sh)
# supersedes them; forks are where the cache-tag drift lived.
legacy_pack = False
if (root / "packaging").is_dir() or (root / "tools" / "package_release.ps1").is_file() \
or (root / "tools" / "package_appimage.sh").is_file():
legacy_pack = True
checks.append(
CheckResult(
id="legacy_prebuilt",
title="Hand-forked packaging scripts",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail=(
"Found packaging/, tools/package_release.ps1 or tools/package_appimage.sh. "
"Releases go through scripts/package_release.sh + release.yml; "
"remove the forks once CI is green."
),
fix_op="annotate_legacy_packaging",
)
)
else:
checks.append(
CheckResult(
id="legacy_prebuilt",
title="Hand-forked packaging scripts",
status=CheckStatus.PASS,
severity=Severity.INFO,
detail="No hand-forked packager detected.",
)
)
# VERSION
ver_text = ""
if (root / "VERSION").is_file():
ver_text = _read(root / "VERSION").strip()
checks.append(
CheckResult(
id="version_file",
title="VERSION",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail=ver_text or "(empty)",
)
)
else:
checks.append(
CheckResult(
id="version_file",
title="VERSION",
status=CheckStatus.FAIL,
severity=Severity.RECOMMENDED,
detail="Missing VERSION pin file.",
fix_op="emit_version",
)
)
# Lobby pin stamp vs VERSION (build tree) — drift breaks netplay lists.
stamp_hits: list[tuple[str, str]] = []
for build in sorted(root.glob("build*")):
if not build.is_dir():
continue
for stamp in (
build / "psx_game_version.txt",
build / "Release" / "psx_game_version.txt",
):
if stamp.is_file():
stamp_hits.append(
(str(stamp.relative_to(root)).replace("\\", "/"),
_read(stamp).strip())
)
if ver_text and stamp_hits:
bad = [(p, s) for p, s in stamp_hits if s and s.lstrip("vV") != ver_text.lstrip("vV")]
if bad:
detail = "; ".join(f"{p}={s} (VERSION={ver_text})" for p, s in bad[:3])
checks.append(
CheckResult(
id="version_stamp_match",
title="Lobby pin stamp",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
"psx_game_version.txt disagrees with VERSION — "
"rebuild with -DPSX_GAME_VERSION matching VERSION before "
"packaging/releasing. " + detail
),
)
)
else:
checks.append(
CheckResult(
id="version_stamp_match",
title="Lobby pin stamp",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail="Build stamp matches VERSION.",
)
)
# symbols
if (root / "symbols.toml").is_file():
checks.append(
CheckResult(
id="symbols_toml",
title="symbols.toml",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail="symbols.toml present.",
)
)
else:
checks.append(
CheckResult(
id="symbols_toml",
title="symbols.toml",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="Missing symbols.toml progressive map.",
fix_op="emit_symbols_toml",
)
)
sync = root / "tools" / "sync_symbols.py"
if sync.is_file():
checks.append(
CheckResult(
id="sync_symbols",
title="tools/sync_symbols.py",
status=CheckStatus.PASS,
severity=Severity.OPTIONAL,
detail="sync_symbols.py present.",
)
)
else:
checks.append(
CheckResult(
id="sync_symbols",
title="tools/sync_symbols.py",
status=CheckStatus.WARN,
severity=Severity.OPTIONAL,
detail="Missing tools/sync_symbols.py.",
fix_op="emit_sync_symbols",
)
)
# catalog / disc identity
game_toml = _read(root / "game.toml")
has_prepare = "[prepare_disc]" in game_toml
has_catalog = (root / "catalog_identity.json").is_file()
if has_catalog and has_prepare:
checks.append(
CheckResult(
id="disc_identity",
title="Disc / catalog identity",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail="catalog_identity.json + [prepare_disc] present.",
)
)
elif has_prepare or has_catalog:
checks.append(
CheckResult(
id="disc_identity",
title="Disc / catalog identity",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="Partial disc identity — re-run probe_disc with a Redump .cue.",
fix_op="probe_disc_refresh",
)
)
else:
checks.append(
CheckResult(
id="disc_identity",
title="Disc / catalog identity",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="No catalog_identity.json / [prepare_disc] — needed for Retro gates.",
fix_op="probe_disc_refresh",
)
)
# boxart
modern_box = root / "launcher_assets" / "img" / "boxart.tga"
legacy_box_candidates = [
root / "recomp" / "launcher" / "boxart.tga",
root / "recomp" / "launcher" / "boxart.png",
]
legacy_box = next((p for p in legacy_box_candidates if p.is_file()), None)
if modern_box.is_file():
checks.append(
CheckResult(
id="boxart",
title="launcher_assets boxart",
status=CheckStatus.PASS,
severity=Severity.OPTIONAL,
detail="launcher_assets/img/boxart.tga present.",
)
)
elif legacy_box is not None:
checks.append(
CheckResult(
id="boxart",
title="launcher_assets boxart",
status=CheckStatus.WARN,
severity=Severity.OPTIONAL,
detail=f"Boxart at {legacy_box.relative_to(root)} — relocate to launcher_assets/.",
fix_op="relocate_boxart",
)
)
else:
checks.append(
CheckResult(
id="boxart",
title="launcher_assets boxart",
status=CheckStatus.WARN,
severity=Severity.OPTIONAL,
detail="No boxart found (optional).",
fix_op="emit_boxart_stub",
)
)
# Default Retro-themed app icon (Windows .ico + PNG)
app_ico = root / "assets" / "psxrecomp.ico"
if app_ico.is_file():
checks.append(
CheckResult(
id="app_icon",
title="assets/psxrecomp app icon",
status=CheckStatus.PASS,
severity=Severity.OPTIONAL,
detail="assets/psxrecomp.ico present.",
)
)
else:
checks.append(
CheckResult(
id="app_icon",
title="assets/psxrecomp app icon",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="Missing assets/psxrecomp.ico (Retro-themed default pad icon).",
fix_op="ensure_app_icon",
)
)
# gitignore essentials
gi = _read(root / ".gitignore")
missing_gi = [
pat
for pat in ("/disc/", "/bios/", "/dist/", "/analysis/")
if pat not in gi and pat.rstrip("/") not in gi
]
ignores_generated = any(
re.match(r"^\s*/?generated/?\s*$", line) for line in gi.splitlines()
)
if not (root / ".gitignore").is_file():
checks.append(
CheckResult(
id="gitignore",
title=".gitignore rules",
status=CheckStatus.FAIL,
severity=Severity.RECOMMENDED,
detail="Missing .gitignore.",
fix_op="merge_gitignore",
)
)
elif ignores_generated:
checks.append(
CheckResult(
id="gitignore",
title=".gitignore rules",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
".gitignore ignores generated/ (the retired setup-host rule). Releases "
"build the committed game C, so CI would find nothing to compile."
),
fix_op="merge_gitignore",
)
)
elif missing_gi:
checks.append(
CheckResult(
id="gitignore",
title=".gitignore rules",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="Missing patterns: " + ", ".join(missing_gi),
fix_op="merge_gitignore",
)
)
else:
checks.append(
CheckResult(
id="gitignore",
title=".gitignore rules",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail="Disc / BIOS / dist / analysis ignored; generated/ tracked.",
)
)
# Committed game C: the release's input. Present and tracked, or the
# workflow has nothing to build.
if boot:
marker = root / "generated" / f"{boot}_dispatch.c"
tracked = False
if marker.is_file():
try:
r = subprocess.run(
["git", "-C", str(root), "ls-files", "--error-unmatch",
f"generated/{boot}_dispatch.c"],
capture_output=True, text=True, check=False,
)
tracked = r.returncode == 0
except OSError:
tracked = False
if not marker.is_file():
checks.append(
CheckResult(
id="generated_committed",
title="Committed game C",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
f"generated/{boot}_dispatch.c is missing. Generate once from a legal "
"disc (psxrecomp_cli.py generate) and commit generated/."
),
)
)
elif not tracked:
checks.append(
CheckResult(
id="generated_committed",
title="Committed game C",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
f"generated/{boot}_dispatch.c exists but is not tracked by git; "
"release CI checks out the repo and would find nothing to build. "
"git add generated."
),
)
)
else:
checks.append(
CheckResult(
id="generated_committed",
title="Committed game C",
status=CheckStatus.PASS,
severity=Severity.REQUIRED,
detail=f"generated/{boot}_dispatch.c tracked.",
)
)
retail_c = sorted(
p.relative_to(root).as_posix()
for d in (root / "generated", root / "psxrecomp" / "generated")
if d.is_dir()
for p in d.glob("SCPH*_*.c")
)
if retail_c:
checks.append(
CheckResult(
id="retail_bios_c",
title="Retail BIOS C absent",
status=CheckStatus.FAIL,
severity=Severity.REQUIRED,
detail=(
"Retail-BIOS-derived C present: " + ", ".join(retail_c[:4])
+ ". It must never be committed or shipped; only OpenBIOS links in a release."
),
)
)
# pins
if (root / "framework_pins.txt").is_file():
checks.append(
CheckResult(
id="framework_pins",
title="framework_pins.txt",
status=CheckStatus.PASS,
severity=Severity.OPTIONAL,
detail="Pin snapshot present (gitlinks remain authoritative).",
)
)
else:
checks.append(
CheckResult(
id="framework_pins",
title="framework_pins.txt",
status=CheckStatus.WARN,
severity=Severity.OPTIONAL,
detail="No framework_pins.txt snapshot.",
fix_op="record_framework_pins",
)
)
# mods preloaded stub
if (root / "mods" / "preloaded").is_dir():
checks.append(
CheckResult(
id="mods_preloaded",
title="mods/preloaded",
status=CheckStatus.PASS,
severity=Severity.OPTIONAL,
detail="mods/preloaded present.",
)
)
else:
checks.append(
CheckResult(
id="mods_preloaded",
title="mods/preloaded",
status=CheckStatus.WARN,
severity=Severity.OPTIONAL,
detail="No mods/preloaded catalog stub.",
fix_op="emit_mods_preloaded",
)
)
# README download badges + boxart + Retro Launcher (idempotent migrate op)
readme_path = root / "README.md"
readme_text = _read(readme_path) if readme_path.is_file() else ""
missing_readme: list[str] = []
if not readme_path.is_file():
missing_readme.append("README.md")
else:
if not readme_has_metrics(readme_text):
missing_readme.append("download badges")
if not readme_has_boxart(readme_text):
missing_readme.append("libretro boxart")
if not readme_has_launcher(readme_text):
missing_readme.append("Retro Launcher section")
if not readme_has_raid(readme_text):
missing_readme.append("R.A.I.D. Discord footer")
if not (root / ".github" / "raid-discord.png").is_file():
missing_readme.append(".github/raid-discord.png")
if not boxart_png_present(root):
missing_readme.append("launcher_assets/img/boxart.png")
if missing_readme:
checks.append(
CheckResult(
id="readme_metrics",
title="README download metrics / launcher / RAID / boxart",
status=CheckStatus.WARN,
severity=Severity.RECOMMENDED,
detail="Missing: " + ", ".join(missing_readme),
fix_op="patch_readme_metrics",
)
)
else:
checks.append(
CheckResult(
id="readme_metrics",
title="README download metrics / launcher / RAID / boxart",
status=CheckStatus.PASS,
severity=Severity.RECOMMENDED,
detail="Download badges, boxart, Retro Launcher, and R.A.I.D. footer present.",
)
)
# Classify layout
fails = {c.id for c in checks if c.status == CheckStatus.FAIL}
rec_warns = [
c
for c in checks
if c.status == CheckStatus.WARN and c.severity == Severity.RECOMMENDED
]
if legacy_pack or "psxrecomp_add_runtime_target" in cmake or has_v4:
layout = LayoutClass.LEGACY_PACKAGING
elif fails:
if packager is not None or wizard_ok or (root / "codegen_setup.c").is_file():
layout = LayoutClass.SETUP_HOST_PARTIAL
else:
layout = LayoutClass.UNKNOWN
elif rec_warns:
layout = LayoutClass.SETUP_HOST_PARTIAL
else:
layout = LayoutClass.SCAFFOLD_COMPLETE
if boot is None:
notes.append("Could not parse [game].exe from game.toml — set --boot-exe when applying.")
notes.append(
"Policy: bundled releases -- generated/ is committed and CI ships the compiled game."
)
return AuditReport(
root=str(root),
layout=layout,
project_name=name,
boot_exe=boot,
checks=checks,
notes=notes,
)