Files
psxrecomp/tools/scan_branch_delay_hazards.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

587 lines
21 KiB
Python

#!/usr/bin/env python3
"""
scan_branch_delay_hazards.py
Phase 1b B(5) safety scan + translator-fix verification.
Background
==========
Real R3000A: a conditional branch's decision uses rs/rt values from
BEFORE the delay-slot instruction executes. The slice walker
(bios_slice_walker.cpp) emits the delay-slot C code before the branch
terminator C code, so a naive translator that read cpu->gpr[rs/rt] in
the branch terminator would see POST-delay-slot state — wrong whenever
the delay slot writes to one of the branch's own operands.
This scanner does two things:
1. Counts, for the entire BIOS (every Ghidra function plus the
reset-vector and BEV=1 GE vector synthetic seeds), how many branches
have a delay-slot instruction that writes to that branch's rs or rt.
This is the "would-be-hazardous-without-snapshot" set. It's an
intrinsic property of the BIOS bytes; the translator can't shrink it.
2. Verifies that the strict translator handles ALL conditional branches
via the pre_delay_code snapshot pattern. The check is structural:
open recompiler/src/strict_translator.cpp and confirm every branch
case (op 0x04, 0x05, 0x06, 0x07, REGIMM rt 0x00, REGIMM rt 0x01)
sets `r.pre_delay_code = ...` and reads only `psx_brA_*` /
`psx_brB_*` snapshot variables in `r.c_code` (never `cpu->gpr[`).
If any branch case fails the check, the scanner reports it as
`incorrect_in_current_translator` and exits non-zero.
Output
======
generated/branch_delay_hazards.json with:
- totals.branches_scanned
- totals.delay_slot_writes_branch_operand (informational, intrinsic)
- totals.incorrect_in_current_translator (must be 0 after the fix)
- translator_check.<branch_op>: pass/fail status
- hazards: list of (branch, delay_slot) pairs (informational)
Exit code is non-zero if `incorrect_in_current_translator > 0`, OR if
`--fail-on-hazard` is passed and any informational hazards exist.
"""
import json
import os
import struct
import sys
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import audit_config
# These are populated by main() from the config. Module-level globals are
# preserved to minimize churn against the existing walker/fetch helpers.
ROOT = None
BIOS_PATH = None
STARTS_PATH = None
OUT_PATH = None
IMAGE_BASE = None
IMAGE_END = None
ROM_SIZE = None
# BIOS-specific synthetic entry seeds (reset vector + BEV=1 exception vector).
# These don't appear in ghidra_function_starts.json but must be walked to
# cover boot-time hazards. For non-BIOS configs they don't apply.
BIOS_SYNTHETIC_SEEDS = [
(0xBFC00000, 0xBFC00420, "reset_vector"),
(0xBFC00180, 0xBFC00420, "boot_general_exception_bev1"),
]
def fetch(rom, addr):
off = addr - IMAGE_BASE
if off < 0 or off + 4 > ROM_SIZE:
return None
return struct.unpack_from("<I", rom, off)[0]
def is_cond_branch(raw):
"""Return (mnem, rs, rt_or_None) if `raw` is one of the conditional
branches we implemented in B(5), else None. rt is None for the
branches that don't have a meaningful rt operand."""
op = (raw >> 26) & 0x3F
rs = (raw >> 21) & 0x1F
rt = (raw >> 16) & 0x1F
if op == 0x04:
return ("BEQ", rs, rt)
if op == 0x05:
return ("BNE", rs, rt)
if op == 0x06:
return ("BLEZ", rs, None)
if op == 0x07:
return ("BGTZ", rs, None)
if op == 0x01:
if rt == 0x00:
return ("BLTZ", rs, None)
if rt == 0x01:
return ("BGEZ", rs, None)
return None
def instr_writes_to_gpr(raw):
"""Return the set of GPR indices that `raw` writes to (architectural
destination only; HI/LO writes don't count for this scan since
branches don't read HI/LO).
Conservative: if we don't recognize the opcode, return empty set
rather than guessing — the scan would then under-report hazards for
unrecognized opcodes. To avoid that we panic on unknown opcodes; the
instruction inventory already guarantees every BIOS opcode is in our
classification table.
"""
if raw == 0:
return set() # nop
op = (raw >> 26) & 0x3F
rs = (raw >> 21) & 0x1F
rt = (raw >> 16) & 0x1F
rd = (raw >> 11) & 0x1F
# SPECIAL
if op == 0x00:
funct = raw & 0x3F
# R-type ALU/shift writes rd
if funct in (0x00, 0x02, 0x03, 0x04, 0x06, 0x07,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x2A, 0x2B):
return {rd}
# MFHI/MFLO write rd
if funct in (0x10, 0x12):
return {rd}
# MTHI/MTLO write HI/LO only
if funct in (0x11, 0x13):
return set()
# MULT/MULTU/DIV/DIVU write HI/LO only
if funct in (0x18, 0x19, 0x1A, 0x1B):
return set()
# JR: no GPR write
if funct == 0x08:
return set()
# JALR: writes rd ($ra default = 31 if rd==0 not encoded;
# technically rd field is the link reg, $ra is just the
# default name)
if funct == 0x09:
return {rd}
# SYSCALL/BREAK: no architectural GPR write (handler may, but
# for the in-line trap pattern the BIOS uses, treat as none)
if funct in (0x0C, 0x0D):
return set()
return set()
# REGIMM: BLTZ/BGEZ no write; BLTZAL/BGEZAL write $31
if op == 0x01:
if rt in (0x10, 0x11):
return {31}
return set()
# J / JAL
if op == 0x02:
return set()
if op == 0x03:
return {31}
# Conditional branches: no GPR write
if op in (0x04, 0x05, 0x06, 0x07):
return set()
# I-type ALU writes rt
if op in (0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F):
return {rt}
# COP0
if op == 0x10:
# MFC0 (rs=0): writes rt
# MTC0 (rs=4): writes COP0 reg, no GPR
# CO=1 funct (rs=0x10): RFE etc., no GPR
rs_field = (raw >> 21) & 0x1F
if rs_field == 0x00:
return {rt}
return set()
# COP1: no GPR writes for the FPU instructions the BIOS contains
# (mtc1/mov.d/etc. either move into FPRs or compute on FPRs).
# bc1f/bc1t are conditional branches with no GPR write.
if op == 0x11:
return set()
# COP2 (GTE): not in BIOS inventory, but be permissive
if op == 0x12:
return set()
# Loads: write rt
if op in (0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26):
return {rt}
# Stores: no GPR write
if op in (0x28, 0x29, 0x2A, 0x2B, 0x2E):
return set()
# LWC1/SWC1 etc.: no GPR write
if op in (0x30, 0x31, 0x32, 0x33, 0x38, 0x39, 0x3A, 0x3B):
return set()
# Unknown opcode: don't guess
raise RuntimeError(
f"unknown primary opcode 0x{op:02X} in instr_writes_to_gpr (raw=0x{raw:08X})"
)
# Reuse the same BFS walker as build_instruction_inventory.py via a
# simplified inline copy. Kept independent so this scan stays self-
# contained.
def branch_target(addr, raw):
op = (raw >> 26) & 0x3F
if op in (0x04, 0x05, 0x06, 0x07):
simm = raw & 0xFFFF
if simm & 0x8000:
simm -= 0x10000
return ("cond_branch", addr + 4 + (simm << 2))
if op == 0x01:
simm = raw & 0xFFFF
if simm & 0x8000:
simm -= 0x10000
return ("cond_branch", addr + 4 + (simm << 2))
if op == 0x02:
return ("jump", ((addr + 4) & 0xF0000000) | ((raw & 0x03FFFFFF) << 2))
if op == 0x03:
return ("call", ((addr + 4) & 0xF0000000) | ((raw & 0x03FFFFFF) << 2))
if op == 0x00:
funct = raw & 0x3F
if funct == 0x08:
return ("jr", None)
if funct == 0x09:
return ("call", None)
if funct == 0x0C:
return ("syscall", None)
if funct == 0x0D:
return ("break", None)
if op in (0x11, 0x12):
rs = (raw >> 21) & 0x1F
if rs == 0x08:
simm = raw & 0xFFFF
if simm & 0x8000:
simm -= 0x10000
return ("cond_branch", addr + 4 + (simm << 2))
return ("normal", None)
def walk(rom, entry, hard_cap):
visited = {}
work = [entry]
def visit_delay_slot(addr):
if addr in visited:
return
if addr < entry or addr >= hard_cap or addr > IMAGE_END:
return
raw = fetch(rom, addr)
if raw is None:
return
visited[addr] = raw
while work:
addr = work.pop()
if addr in visited:
continue
if addr < entry or addr >= hard_cap or addr > IMAGE_END:
continue
raw = fetch(rom, addr)
if raw is None:
continue
visited[addr] = raw
kind, target = branch_target(addr, raw)
delay = addr + 4
if kind == "jr":
visit_delay_slot(delay)
continue
if kind == "jump":
visit_delay_slot(delay)
if target is not None and entry <= target < hard_cap:
work.append(target)
continue
if kind == "cond_branch":
visit_delay_slot(delay)
work.append(addr + 8)
if target is not None and entry <= target < hard_cap:
work.append(target)
continue
if kind == "call":
visit_delay_slot(delay)
work.append(addr + 8)
continue
if kind in ("syscall", "break"):
work.append(addr + 4)
continue
work.append(addr + 4)
return visited
def main():
global ROOT, BIOS_PATH, STARTS_PATH, OUT_PATH, IMAGE_BASE, IMAGE_END, ROM_SIZE
cfg = audit_config.from_argv(sys.argv)
ROOT = str(cfg.project_root)
BIOS_PATH = str(cfg.rom)
OUT_PATH = str(cfg.out_dir / "branch_delay_hazards.json")
IMAGE_BASE = cfg.load_address
IMAGE_END = cfg.load_address + cfg.text_size - 1
ROM_SIZE = cfg.text_size
print(f"# branch_delay_hazards: {cfg.name}")
print(f" rom = {BIOS_PATH}")
print(f" image base = 0x{IMAGE_BASE:08X}")
print(f" image end = 0x{IMAGE_END:08X}")
print(f" out = {OUT_PATH}")
if not cfg.function_starts:
print(f" function_starts: <not configured for this program>")
print()
print("Branch-hazard walker requires a function_starts JSON to seed "
"the reachable-code walk. This config has none (typical for "
"games using dynamic discovery). The walker-based hazard "
"scan is skipped. The translator-source check still runs.")
translator_check = verify_translator_uses_snapshots()
incorrect = sum(1 for v in translator_check.values() if not v["pass"])
print(f"\n translator-source check: "
f"{'PASS' if incorrect == 0 else 'FAIL'}")
if incorrect:
for op_name, info in translator_check.items():
if not info["pass"]:
print(f" FAIL {op_name}: {info['reason']}", file=sys.stderr)
return 8
# Returning OK because the walker check simply doesn't apply to
# this config; the translator-source invariant is still upheld.
return 0
STARTS_PATH = str(cfg.function_starts)
with open(BIOS_PATH, "rb") as f:
rom = f.read()
with open(STARTS_PATH, "r", encoding="utf-8") as f:
starts_doc = json.load(f)
starts = [int(s, 16) for s in starts_doc["function_starts"]]
# Union all walked addresses across all seeds.
all_instrs = {}
# Synthetic seeds: BIOS-specific reset/exception vectors. Only apply
# to the BIOS image — recognized by load_address.
if cfg.load_address == 0xBFC00000:
for (entry, cap, _label) in BIOS_SYNTHETIC_SEEDS:
for addr, raw in walk(rom, entry, cap).items():
all_instrs[addr] = raw
for i, entry in enumerate(starts):
cap = starts[i + 1] if i + 1 < len(starts) else (IMAGE_END + 1)
for addr, raw in walk(rom, entry, cap).items():
all_instrs[addr] = raw
# Now scan every conditional branch.
hazards = []
safe_count = 0
branch_count = 0
no_delay_in_walk = 0
for addr in sorted(all_instrs.keys()):
raw = all_instrs[addr]
cb = is_cond_branch(raw)
if cb is None:
continue
branch_count += 1
mnem, br_rs, br_rt = cb
delay_addr = addr + 4
delay_raw = all_instrs.get(delay_addr)
if delay_raw is None:
# The walker may not have visited the delay slot if the
# branch sits at the very end of the walked region. Fetch
# directly from ROM as a fallback so the scan is complete.
delay_raw = fetch(rom, delay_addr)
if delay_raw is None:
no_delay_in_walk += 1
continue
try:
written = instr_writes_to_gpr(delay_raw)
except RuntimeError as e:
return _bail(f"unknown delay-slot opcode at 0x{delay_addr:08x}: {e}")
flagged = []
if br_rs in written and br_rs != 0:
flagged.append("rs")
if br_rt is not None and br_rt in written and br_rt != 0:
flagged.append("rt")
if flagged:
hazards.append({
"branch_addr": f"0x{addr:08x}",
"branch_raw": f"0x{raw:08x}",
"branch_mnem": mnem,
"branch_rs": f"0x{br_rs:02x}",
"branch_rt": f"0x{br_rt:02x}" if br_rt is not None else None,
"delay_addr": f"0x{delay_addr:08x}",
"delay_raw": f"0x{delay_raw:08x}",
"delay_writes": flagged,
"hazard": True,
})
else:
safe_count += 1
# Translator-fix verification: confirm strict_translator.cpp handles
# every branch via the pre_delay_code snapshot pattern.
translator_check = verify_translator_uses_snapshots()
incorrect_in_translator = sum(
1 for v in translator_check.values() if not v["pass"]
)
out = {
"schema": "psxrecomp phase1b branch_delay_hazards v2",
"program": cfg.name,
"totals": {
"branches_scanned": branch_count,
"delay_slot_writes_branch_operand": len(hazards),
"incorrect_in_current_translator": incorrect_in_translator,
"delay_slot_unreachable_in_walk": no_delay_in_walk,
},
"translator_check": translator_check,
"rationale": (
"Phase 1b B(5) implements conditional branches as slice-walker "
"terminators. The walker (with the corrected emit contract) "
"emits TranslateResult.pre_delay_code BEFORE the delay slot "
"and the terminator c_code AFTER it. The strict translator's "
"branch handlers populate pre_delay_code with `uint32_t "
"psx_brA_<addr> = cpu->gpr[rs];` (and psx_brB_ for "
"BEQ/BNE), and the c_code reads ONLY those snapshot locals. "
"This makes the architectural rs/rt-before-delay rule "
"correct for every branch regardless of what the delay slot "
"writes. The `delay_slot_writes_branch_operand` count is an "
"intrinsic property of the BIOS bytes (it cannot be reduced "
"by translator work); `incorrect_in_current_translator` is "
"the count that matters for correctness and must be 0."
),
"hazards": hazards,
}
with open(OUT_PATH, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
print(f"Wrote {OUT_PATH}")
print(f" branches scanned : {branch_count}")
print(f" delay slot writes branch operand : {len(hazards)} (informational)")
print(f" incorrect in current translator : {incorrect_in_translator}")
print(f" delay-slot unreachable in walk : {no_delay_in_walk}")
if incorrect_in_translator > 0:
print("ERROR: strict translator does not snapshot all branch operands",
file=sys.stderr)
for op_name, info in translator_check.items():
if not info["pass"]:
print(f" FAIL {op_name}: {info['reason']}", file=sys.stderr)
return 8
if "--fail-on-hazard" in sys.argv and hazards:
return 6
return 0
def verify_translator_uses_snapshots():
"""Open recompiler/src/strict_translator.cpp and confirm that every
conditional-branch handler:
1. Sets r.pre_delay_code (non-empty assignment).
2. Uses only snapshot variables (psx_brA_/psx_brB_) in r.c_code,
and never reads cpu->gpr[ in the c_code body.
Returns a dict { branch_name -> {pass: bool, reason: str} }.
"""
# strict_translator.cpp lives in psxrecomp regardless of which
# program config is being audited. Resolve relative to this script.
psxrecomp_root = Path(__file__).parent.parent
src_path = psxrecomp_root / "recompiler" / "src" / "strict_translator.cpp"
with open(src_path, "r", encoding="utf-8") as f:
text = f.read()
# Find the comment marker that starts the branch section, and only
# check from there to the end of the REGIMM block. This avoids false
# positives from earlier instruction handlers.
section_start = text.find("Conditional branches (Phase 1b B(5)")
if section_start < 0:
return {"_section": {"pass": False,
"reason": "branch section marker not found in strict_translator.cpp"}}
# End of section: the J handler comment that follows.
section_end = text.find("// J (0x02)", section_start)
if section_end < 0:
section_end = len(text)
section = text[section_start:section_end]
results = {}
branch_specs = [
("BEQ_op04", "if (opcode == 0x04)", True), # has rt
("BNE_op05", "if (opcode == 0x05)", True),
("BLEZ_op06", "if (opcode == 0x06)", False),
("BGTZ_op07", "if (opcode == 0x07)", False),
# REGIMM (op 0x01) handles BLTZ + BGEZ in one block
("REGIMM_op01", "if (opcode == 0x01)", False),
]
for name, marker, has_rt in branch_specs:
marker_pos = section.find(marker)
if marker_pos < 0:
results[name] = {"pass": False, "reason": f"handler marker `{marker}` not found"}
continue
# Capture the handler block. Use a simple brace counter from the
# opening `{` after marker_pos to the matching `}`.
body_start = section.find("{", marker_pos)
if body_start < 0:
results[name] = {"pass": False, "reason": "no `{` after marker"}
continue
depth = 0
i = body_start
body_end = -1
while i < len(section):
ch = section[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
body_end = i + 1
break
i += 1
if body_end < 0:
results[name] = {"pass": False, "reason": "unbalanced braces in handler"}
continue
body = section[body_start:body_end]
# Rule 1: pre_delay_code must be assigned with snapshot pattern
if "r.pre_delay_code" not in body:
results[name] = {"pass": False, "reason": "no r.pre_delay_code assignment"}
continue
if "psx_brA_" not in body:
results[name] = {"pass": False, "reason": "snapshot variable psx_brA_* not used"}
continue
if has_rt and "psx_brB_" not in body:
results[name] = {"pass": False, "reason": "snapshot variable psx_brB_* not used (BEQ/BNE need rt snapshot)"}
continue
# Rule 2: r.c_code must NOT read cpu->gpr[
# Find the r.c_code = fmt::format(...) string and check that
# the format string contains no `cpu->gpr[` substring.
c_code_pos = body.find("r.c_code = fmt::format(")
if c_code_pos < 0:
results[name] = {"pass": False, "reason": "no r.c_code = fmt::format(...) assignment"}
continue
# Walk forward to the matching `);` of fmt::format. Track parens.
paren_start = body.find("(", c_code_pos)
depth = 0
j = paren_start
c_code_end = -1
while j < len(body):
if body[j] == "(":
depth += 1
elif body[j] == ")":
depth -= 1
if depth == 0:
c_code_end = j + 1
break
j += 1
if c_code_end < 0:
results[name] = {"pass": False, "reason": "unbalanced parens in r.c_code = fmt::format(...)"}
continue
c_code_call = body[c_code_pos:c_code_end]
if "cpu->gpr[" in c_code_call:
results[name] = {"pass": False,
"reason": "r.c_code reads cpu->gpr[...] directly — must use psx_brA_/psx_brB_ snapshot variables only"}
continue
results[name] = {"pass": True, "reason": "snapshot pattern verified"}
return results
def _bail(msg):
print(f"ERROR: {msg}", file=sys.stderr)
return 7
if __name__ == "__main__":
sys.exit(main())