Files
psxrecomp/tools/audit_config.py
T
Matthew Stanley 6f7fcdc89b Remove all psxrecomp-v4 naming — rename to psxrecomp throughout
The "v4" suffix was a holdover from iterative project restarts.
There is now one psxrecomp; all identifiers, include guards, cmake
variables, function names, log strings, schema tags, and tool paths
drop the v4 suffix.

Changes:
- PSXRECOMP_V4_ROOT → PSXRECOMP_ROOT (cmake)
- PSXRECOMP_V4_RUNTIME_SOURCES/_INCLUDE_DIRS/_BIOS_GENERATED → no V4
- psxrecomp_v4_add_runtime_target → psxrecomp_add_runtime_target
- PSXRECOMP_V4_*_H include guards → PSXRECOMP_*_H
- "psxrecomp-v4" log/window strings → "psxrecomp"
- Schema version strings in recompiler JSON output
- Tool script hardcoded absolute paths updated

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 21:14:30 -07:00

255 lines
8.3 KiB
Python

"""Shared audit-config loader.
Reads a TOML config (BIOS or game) into a normalized object that the
psxrecomp audit tools can consume. See `bios/SCPH1001.toml` and
`TombaRecomp/game.toml` for the schema this module accepts.
Project root is auto-detected by walking up from the config file until a
directory containing `CMakeLists.txt` is found; this works for both
configs placed at project root (game.toml) and configs in a subdirectory
(bios/SCPH1001.toml). All paths in the config are resolved relative to
the detected root.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
try:
import tomllib # Python 3.11+
except ImportError:
import tomli as tomllib # type: ignore
def _hex(s, field_name: str) -> int:
if not isinstance(s, str):
raise ValueError(f"{field_name}: expected hex string, got {type(s).__name__}")
return int(s, 16)
@dataclass(frozen=True)
class Region:
name: str
rom_start: int # byte offset into rom file
rom_end: int # exclusive
vaddr_base: int # virtual address of rom_start
@dataclass(frozen=True)
class Remap:
description: str
from_lo: int # inclusive
from_hi: int # exclusive
to_lo: int # offset target start
@dataclass
class AuditConfig:
# Provenance
config_path: Path
project_root: Path
# Program info
name: str
rom: Path # absolute path to ROM/exe
load_address: int # vaddr of rom_start of first code region
text_size: int # bytes of code segment (for derived bounds)
# Generated artifacts (derived from rom stem + out_dir)
out_dir: Path
full_c: Path
dispatch_c: Path
# Audit-specific
function_starts: Optional[Path]
regions: List[Region]
kseg_mask: int
remaps: List[Remap] = field(default_factory=list)
def normalize_addr(self, addr: int) -> int:
"""Mirror the dispatch-table normalization rules from the config."""
phys = addr & self.kseg_mask
for rm in self.remaps:
if rm.from_lo <= phys < rm.from_hi:
phys = phys - rm.from_lo + rm.to_lo
return phys
def is_in_code_region(self, vaddr: int) -> bool:
"""True if `vaddr` falls inside any declared code region."""
for r in self.regions:
region_end_vaddr = r.vaddr_base + (r.rom_end - r.rom_start)
if r.vaddr_base <= vaddr < region_end_vaddr:
return True
return False
def _find_project_root(config_path: Path) -> Path:
"""Walk up from config_path until we find a root marker.
Markers (any one is sufficient): `.gitignore`, `.git`, `CMakeLists.txt`.
`.gitignore` is the most reliable — both psxrecomp and TombaRecomp
have one at the repo root, while CMakeLists.txt only lives in subdirs
of psxrecomp (recompiler/, runtime/).
"""
cur = config_path.parent.resolve()
for _ in range(8): # bounded search
for marker in (".gitignore", ".git", "CMakeLists.txt"):
if (cur / marker).exists():
return cur
if cur.parent == cur:
break
cur = cur.parent
raise FileNotFoundError(
f"could not locate project root (no .gitignore/.git/CMakeLists.txt) "
f"walking up from {config_path}"
)
def load(config_path) -> AuditConfig:
config_path = Path(config_path).resolve()
if not config_path.exists():
raise FileNotFoundError(f"audit config not found: {config_path}")
root = _find_project_root(config_path)
with open(config_path, "rb") as f:
cfg = tomllib.load(f)
# Program info: [program] (preferred) or [game] (legacy)
prog = cfg.get("program") or cfg.get("game")
if prog is None:
raise KeyError(f"{config_path}: missing [program] or [game] block")
name = prog["name"]
rom_field = prog.get("rom") or prog.get("exe")
if rom_field is None:
raise KeyError(f"{config_path}: program/game block missing rom/exe path")
rom = (root / rom_field).resolve()
load_address = _hex(prog["load_address"], "load_address")
text_size = _hex(prog["text_size"], "text_size")
# Recompiler-derived paths
recomp = cfg.get("recompiler", {})
out_dir_field = recomp.get("out_dir", "generated")
out_dir = (root / out_dir_field).resolve()
# Output filename stem. Explicit override wins; otherwise strip a
# known binary extension (.BIN/.EXE, case-insensitive) from the rom
# name. Plain `Path.stem` is wrong here — it would turn "SCUS_942.36"
# into "SCUS_942" because it treats `.36` as an extension.
out_stem = recomp.get("out_stem")
if not out_stem:
rom_name = Path(rom_field).name
upper = rom_name.upper()
if upper.endswith(".BIN") or upper.endswith(".EXE"):
out_stem = rom_name[:-4]
else:
out_stem = rom_name
full_c = out_dir / f"{out_stem}_full.c"
dispatch_c = out_dir / f"{out_stem}_dispatch.c"
# Audit block
audit = cfg.get("audit")
if audit is None:
raise KeyError(f"{config_path}: missing [audit] block")
fs_field = audit.get("function_starts")
function_starts = (root / fs_field).resolve() if fs_field else None
regions = []
for r in audit.get("regions", []):
regions.append(Region(
name=r["name"],
rom_start=_hex(r["rom_start"], "rom_start"),
rom_end=_hex(r["rom_end"], "rom_end"),
vaddr_base=_hex(r["vaddr_base"], "vaddr_base"),
))
if not regions:
raise ValueError(f"{config_path}: [audit] must declare at least one region")
norm = audit.get("normalize", {})
kseg_mask = _hex(norm.get("kseg_mask", "0xFFFFFFFF"), "kseg_mask")
remaps = []
for rm in norm.get("remap", []):
remaps.append(Remap(
description=rm.get("description", ""),
from_lo=_hex(rm["from_lo"], "from_lo"),
from_hi=_hex(rm["from_hi"], "from_hi"),
to_lo=_hex(rm["to_lo"], "to_lo"),
))
return AuditConfig(
config_path=config_path,
project_root=root,
name=name,
rom=rom,
load_address=load_address,
text_size=text_size,
out_dir=out_dir,
full_c=full_c,
dispatch_c=dispatch_c,
function_starts=function_starts,
regions=regions,
kseg_mask=kseg_mask,
remaps=remaps,
)
def from_argv(argv, default_config: Optional[str] = None) -> AuditConfig:
"""CLI helper: parse `--config PATH` from argv and load.
If `--config` is absent and `default_config` is provided, use that.
Falls back to looking for `audit.toml` or `game.toml` in CWD.
"""
config_path: Optional[str] = None
i = 1
while i < len(argv):
a = argv[i]
if a == "--config" and i + 1 < len(argv):
config_path = argv[i + 1]
i += 2
elif a.startswith("--config="):
config_path = a.split("=", 1)[1]
i += 1
else:
i += 1
if config_path is None:
config_path = default_config
if config_path is None:
for candidate in ("audit.toml", "game.toml"):
p = Path.cwd() / candidate
if p.exists():
config_path = str(p)
break
if config_path is None:
raise SystemExit(
"usage: ... --config PATH (or place audit.toml/game.toml in CWD)"
)
return load(config_path)
if __name__ == "__main__":
import sys
cfg = from_argv(sys.argv)
print(f"loaded {cfg.config_path}")
print(f" project_root = {cfg.project_root}")
print(f" name = {cfg.name}")
print(f" rom = {cfg.rom}")
print(f" full_c = {cfg.full_c}")
print(f" dispatch_c = {cfg.dispatch_c}")
print(f" load_address = 0x{cfg.load_address:08X}")
print(f" text_size = 0x{cfg.text_size:08X}")
print(f" regions = {len(cfg.regions)}")
for r in cfg.regions:
print(f" - {r.name:8s} rom 0x{r.rom_start:05X}..0x{r.rom_end:05X} vaddr 0x{r.vaddr_base:08X}")
print(f" kseg_mask = 0x{cfg.kseg_mask:08X}")
print(f" remaps = {len(cfg.remaps)}")
for rm in cfg.remaps:
print(f" - 0x{rm.from_lo:08X}..0x{rm.from_hi:08X} -> +0x{rm.to_lo:X} ({rm.description})")