Files
psxrecomp/recompiler/tests/test_recompiler_error_exit.py
Shokunin c7ae3c86b2 Guard bltz_sites and branch_keep_sites in the main EXE
docs/WIDESCREEN.md said every explicit screen-X cull kind rejects a wrong
opcode at build time, but only slti, slti_lower, bgez and clip-edge sites
did. A bltz_sites entry was used only when the word was bltz, and
branch_keep_sites wrapped whatever branch was there, so a mistyped address
generated silently and the edge stayed narrow.

One check, CodeGenerator::check_explicit_branch_site, now covers the three
branch kinds in the main EXE: bltz_sites needs bltz (REGIMM rt=0),
bgez_sites bgez (REGIMM rt=1), branch_keep_sites a conditional branch
(REGIMM, beq, bne, blez, bgtz). It runs in generate_branch_condition, in
translate_instruction (non-branch words) and at the block exit, which also
catches a jump at a listed address (jumps never reached either guard, for
bgez_sites too). psxrecomp-game prints the site and exits 1; overlay
variants stay vanilla as before.

ws_cull_edge_codegen_test and recompiler_error_exit cover each kind on a
wrong opcode, a non-branch and a jump, valid sites, and overlay variants;
both fail against the previous emitter. The codegen test also pins that a
matching overlay instruction emits the helpers (the preamble defines them).
Docs list the required instruction per kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:00:17 -07:00

216 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""psxrecomp-game reports bad input with exit status 1, never abort().
Errors reach main() as exceptions: a malformed game.toml (here: a bad
[widescreen.cull] clip_edge_width, a non-hex or non-string site address) and an
input the emitter must refuse (an overlay whose mandatory delay slot lies
outside the image). Uncaught, they ended in abort(), which exits by signal and
leaves a macOS crash report; a returncode != 0 check cannot tell the two
apart, so this test requires exactly 1 and the diagnostic. Main-EXE
[widescreen.cull] sites that hold the wrong opcode exit 1 through their own
guard and are checked the same way.
It also checks that a valid bgez site and a valid branch_keep site still
generate, so a harness problem cannot pass as a rejection.
"""
import argparse
import os
import struct
import subprocess
import sys
import tempfile
LOAD = 0x80010000
# bgez v0,+2 ; nop ; addiu v1,zero,1 ; jr ra ; nop
WORDS = [0x04410002, 0x00000000, 0x24030001, 0x03E00008, 0x00000000]
BASE = """[game]
name = "Cull Config Errors"
id = "TEST-00000"
exe = "t.psx"
entry_pc = "0x80010000"
text_size = "0x14"
stack_base = "0x801FFFF0"
[recompiler]
seeds = "seeds.txt"
out_dir = "out"
[widescreen.cull]
"""
# (name, [widescreen.cull] body, expected exit status, stderr needle)
CASES = [
("clip_edge_width_zero",
'clip_edge_width = 0\nclip_edge_x_load_sites = ["0x80010008"]\n',
1, "clip_edge_width must be 1..1024"),
("clip_edge_width_too_big",
'clip_edge_width = 1025\nclip_edge_x_load_sites = ["0x80010008"]\n',
1, "clip_edge_width must be 1..1024"),
("clip_edge_width_not_integer",
'clip_edge_width = "wide"\nclip_edge_x_load_sites = ["0x80010008"]\n',
1, "ERROR:"),
("bgez_site_not_hex",
'bgez_sites = ["nothex"]\n',
1, "widescreen.cull.bgez_sites"),
("clip_edge_site_not_hex",
'clip_edge_x_load_sites = ["nothex"]\n',
1, "widescreen.cull.clip_edge_x_load_sites"),
("bgez_site_not_string",
"bgez_sites = [65536]\n",
1, "ERROR:"),
("bgez_site_wrong_opcode",
'bgez_sites = ["0x80010008"]\n',
1, "bgez site 0x80010008 is not bgez"),
("clip_edge_site_wrong_opcode",
'clip_edge_x_load_sites = ["0x80010008"]\n',
1, "clip_edge_x_load site 0x80010008 is not lh/lhu/lw"),
("bltz_site_wrong_opcode",
'bltz_sites = ["0x80010008"]\n',
1, "bltz site 0x80010008 is not bltz"),
("bltz_site_on_bgez",
'bltz_sites = ["0x80010000"]\n',
1, "bltz site 0x80010000 is not bltz"),
("branch_keep_site_wrong_opcode",
'branch_keep_sites = ["0x80010008"]\n',
1, "branch_keep site 0x80010008 is not a conditional branch"),
("branch_keep_site_on_jump",
'branch_keep_sites = ["0x8001000C"]\n',
1, "branch_keep site 0x8001000C is not a conditional branch"),
("bgez_site_valid",
'bgez_sites = ["0x80010000"]\n',
0, None),
("branch_keep_site_valid",
'branch_keep_sites = ["0x80010000"]\n',
0, None),
]
# What a valid case must emit, so a harness problem cannot pass as success.
VALID_EMIT = {
"bgez_site_valid": "psx_ws_cull_bgez(",
"branch_keep_site_valid": "ws branch keep",
}
def make_psxexe() -> bytes:
data = b"".join(struct.pack("<I", w) for w in WORDS)
header = bytearray(2048)
header[0:8] = b"PS-X EXE"
struct.pack_into("<I", header, 0x10, LOAD)
struct.pack_into("<I", header, 0x18, LOAD)
struct.pack_into("<I", header, 0x1C, len(data))
return bytes(header) + data
def run_case(recompiler, project_root, root, name, body, flag):
case_dir = os.path.join(root, f"{name}{flag.replace('-', '_')}")
out_dir = os.path.join(case_dir, "out")
os.makedirs(out_dir)
with open(os.path.join(case_dir, "t.psx"), "wb") as f:
f.write(make_psxexe())
with open(os.path.join(case_dir, "seeds.txt"), "w", encoding="ascii") as f:
f.write(f"0x{LOAD:08X}\n")
config = os.path.join(case_dir, "game.toml")
with open(config, "w", encoding="ascii") as f:
f.write(BASE + body)
if flag == "--config":
cmd = [recompiler, "--config", config, "--project-root", project_root]
else:
cmd = [recompiler, os.path.join(case_dir, "t.psx"),
"--seeds", os.path.join(case_dir, "seeds.txt"),
"--out-dir", out_dir, "--project-root", project_root,
"--ws-config", config]
proc = subprocess.run(cmd, cwd=case_dir, capture_output=True, text=True)
return proc, out_dir
def run_truncated_overlay(recompiler, project_root, root):
"""An overlay page ending in a branch whose delay slot is not in the
image: generation must fail closed (see
test_overlay_cross_page_delay_codegen.py)."""
case_dir = os.path.join(root, "truncated_overlay")
out_dir = os.path.join(case_dir, "out")
os.makedirs(out_dir)
load = 0x80010FF0
words = [0, 0, 0, 0x10800008] # nops; beq a0,zero,+8 with no delay slot
data = b"".join(struct.pack("<I", w) for w in words)
header = bytearray(2048)
header[0:8] = b"PS-X EXE"
struct.pack_into("<I", header, 0x10, load)
struct.pack_into("<I", header, 0x18, load)
struct.pack_into("<I", header, 0x1C, len(data))
psx = os.path.join(case_dir, "cross_page.psx")
with open(psx, "wb") as f:
f.write(bytes(header) + data)
seeds = os.path.join(case_dir, "seeds.txt")
with open(seeds, "w", encoding="ascii") as f:
f.write(f"dispatch_root 0x{load:08X}\n")
return subprocess.run(
[recompiler, psx, "--seeds", seeds, "--out-dir", out_dir, "--overlay",
"--project-root", project_root],
cwd=case_dir, capture_output=True, text=True)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--recompiler", required=True)
parser.add_argument("--project-root", required=True,
help="psxrecomp root (holds bios/SCPH1001.toml)")
args = parser.parse_args()
args.recompiler = os.path.abspath(args.recompiler)
args.project_root = os.path.abspath(args.project_root)
if not os.path.isfile(args.recompiler):
raise SystemExit(f"recompiler not found: {args.recompiler}")
failures = []
with tempfile.TemporaryDirectory() as root:
for name, body, want_rc, needle in CASES:
for flag in ("--config", "--ws-config"):
proc, out_dir = run_case(args.recompiler, args.project_root,
root, name, body, flag)
label = f"{name} ({flag})"
if proc.returncode != want_rc:
how = (f"signal {-proc.returncode}" if proc.returncode < 0
else f"status {proc.returncode}")
failures.append(f"{label}: exited with {how}, want status "
f"{want_rc}\n{proc.stderr[-600:]}")
continue
if needle is not None and needle not in proc.stderr:
failures.append(f"{label}: stderr lacks {needle!r}\n"
f"{proc.stderr[-600:]}")
if want_rc == 0:
generated = ""
for fname in os.listdir(out_dir):
if fname.endswith(".c"):
with open(os.path.join(out_dir, fname),
encoding="utf-8") as f:
generated += f.read()
if VALID_EMIT[name] not in generated:
failures.append(f"{label}: valid site was not "
f"emitted ({VALID_EMIT[name]!r})")
proc = run_truncated_overlay(args.recompiler, args.project_root, root)
if proc.returncode != 1:
how = (f"signal {-proc.returncode}" if proc.returncode < 0
else f"status {proc.returncode}")
failures.append(f"truncated overlay: exited with {how}, want "
f"status 1\n{proc.stderr[-600:]}")
elif "ERROR:" not in proc.stderr or \
"mandatory delay slot" not in proc.stderr:
failures.append("truncated overlay: stderr lacks the ERROR: "
f"mandatory delay slot diagnostic\n"
f"{proc.stderr[-600:]}")
for failure in failures:
print(f"FAIL: {failure}", file=sys.stderr)
if failures:
return 1
print(f"PASS: {len(CASES) * 2} widescreen.cull config cases and a "
"truncated overlay exit as expected; valid sites generate")
return 0
if __name__ == "__main__":
sys.exit(main())