235 lines
8.0 KiB
Python
235 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Verify Pass 1 (code/data split) across every top-level project.
|
|
|
|
The repo's hard contract is a byte-identical rebuild, and that is what proves
|
|
the sources are *faithful*: nothing lost, nothing reformatted. It says nothing
|
|
about whether Pass 1 is *complete* — a project can reproduce all 16,384 bytes
|
|
perfectly while calling 95% of them data because the tracer gave up early.
|
|
|
|
So there are two independent questions:
|
|
|
|
1. **Faithful?** Does the source still rebuild byte-identical?
|
|
2. **Complete?** Does the committed source claim as much code as a fresh
|
|
trace can actually reach?
|
|
|
|
Completeness is checked by re-running the tracer and comparing instruction
|
|
counts. Comparing *lines* rather than bytes keeps this independent of comment
|
|
style, which matters because the audit-comment format is not uniform across the
|
|
repo (see CONVENTIONS below).
|
|
|
|
Each project is traced twice: with the 151 official opcodes, and with the 105
|
|
undocumented NMOS opcodes added. The gap between the two separates "the tracer
|
|
was too weak" from "genuinely unreachable".
|
|
|
|
Verdicts
|
|
--------
|
|
OK committed split agrees with the tracer
|
|
SEEDED tracer finds materially LESS than committed - the project used
|
|
auto-seed / dispatch-table recovery, so a plain vector trace
|
|
cannot judge it. Not a defect.
|
|
TRUNCATED tracer finds materially MORE than committed - Pass 1 stopped
|
|
early and real code was written out as data. A defect.
|
|
|
|
CONVENTIONS (reported, not enforced)
|
|
-----------------------------------
|
|
AGENTS.md requires every instruction to carry `; CPU:$xxxx PRG:xxxx AA BB`.
|
|
Several projects predate or ignore that. This tool reports which spelling each
|
|
project uses so the drift is visible.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import nesdis # noqa: E402
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
# Comment spellings actually found in the repo, most-preferred first.
|
|
AUDIT_FORMS = {
|
|
# re.M matters: the trailing $ anchors the byte list to end of *line*. Without
|
|
# it the pattern only ever matches once, at end of file, so every project was
|
|
# reported as a non-canonical spelling.
|
|
"canonical CPU:$x PRG:xxxx AA BB":
|
|
re.compile(r"CPU:\$?([0-9A-Fa-f]{4})\s+PRG[: ]\$?([0-9A-Fa-f]{4})\s+"
|
|
r"((?:[0-9A-Fa-f]{2}[ ]?)+?)\s*(?:--.*)?$", re.M),
|
|
"pipe PRG $xxxx | CPU $xxxx | AA BB":
|
|
re.compile(r"PRG \$([0-9A-Fa-f]{4})\s*\|\s*CPU \$([0-9A-Fa-f]{4})\s*\|\s*"
|
|
r"((?:[0-9A-Fa-f]{2} )+)"),
|
|
"colon PRG:$xxxx AA BB":
|
|
re.compile(r"PRG:\$?([0-9A-Fa-f]{4})\s+((?:[0-9A-Fa-f]{2} )+)"),
|
|
"slash PRG $xxxx / CPU $xxxx: AA BB":
|
|
re.compile(r"PRG \$([0-9A-Fa-f]{4})\s*/\s*CPU \$[0-9A-Fa-f]{4}:\s*"
|
|
r"((?:[0-9A-Fa-f]{2} )+)"),
|
|
"short $xxxx: AA BB":
|
|
re.compile(r"\$\$?([0-9A-Fa-f]{4}):\s*((?:[0-9A-Fa-f]{2} )+)"),
|
|
"bytes PRG:$xxxx Bytes:AA BB":
|
|
re.compile(r"PRG:\$?[0-9A-Fa-f]{4}\s+Bytes:\s*(?:[0-9A-Fa-f]{2} ?)+"),
|
|
"rom ROM $xxxxxx: AA BB":
|
|
re.compile(r"ROM \$([0-9A-Fa-f]{6}):\s*((?:[0-9A-Fa-f]{2} )+)"),
|
|
}
|
|
|
|
# Every mnemonic the tracer can emit, official and undocumented.
|
|
ALL_MNEMONICS = (set(m for m, _ in nesdis.OPCODES.values())
|
|
| set(m for m, _ in nesdis.UNDOC_OPCODES.values()))
|
|
|
|
|
|
def find_source(copilot):
|
|
for name in ("prg.s", "main.s"):
|
|
if (copilot / name).is_file():
|
|
return copilot / name
|
|
return None
|
|
|
|
|
|
def prg_bytes(copilot):
|
|
"""PRG image: prefer the split copy, else carve it out of rom.nes."""
|
|
p = copilot / "prg.bin"
|
|
if p.is_file():
|
|
return p.read_bytes()
|
|
rom = copilot / "rom.nes"
|
|
if rom.is_file():
|
|
r = rom.read_bytes()
|
|
return r[16 + (512 if (r[6] & 4) else 0):16 + r[4] * 16384]
|
|
return None
|
|
|
|
|
|
def prg_base(copilot):
|
|
"""CPU address the PRG is mapped at.
|
|
|
|
Most projects link the PRG segment at its real CPU base ($C000, or $8000
|
|
for MUSCLE). Bomberman instead links one flat file with the iNES header at
|
|
$0000 and PRG at $0010, so its linker start is a *file* offset, not a CPU
|
|
address; fall back to $C000 in that case.
|
|
"""
|
|
m = re.search(r"PRG:\s*start\s*=\s*\$([0-9A-Fa-f]+)",
|
|
(copilot / "linker.cfg").read_text())
|
|
if not m:
|
|
return 0xC000
|
|
start = int(m.group(1), 16)
|
|
return start if start >= 0x8000 else 0xC000
|
|
|
|
|
|
def count_instructions(src):
|
|
"""Instruction lines in the committed source. Comment/directive agnostic."""
|
|
n = 0
|
|
for raw in src.read_text().splitlines():
|
|
s = raw.strip()
|
|
if not s or s.startswith((";", ".")):
|
|
continue
|
|
# Macro invocations and symbol assignments are not instructions.
|
|
if s.split()[0].lower() in ALL_MNEMONICS:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def audit_form(src):
|
|
text = src.read_text()
|
|
hits = {}
|
|
for label, rx in AUDIT_FORMS.items():
|
|
n = len(rx.findall(text))
|
|
if n:
|
|
hits[label] = n
|
|
if not hits:
|
|
return "NONE"
|
|
return max(hits, key=hits.get).split()[0]
|
|
|
|
|
|
def builds_byte_identical(copilot):
|
|
"""The faithfulness check: does the project still rebuild its ROM exactly?"""
|
|
try:
|
|
r = subprocess.run(["make"], cwd=copilot, capture_output=True,
|
|
text=True, timeout=600)
|
|
except subprocess.TimeoutExpired:
|
|
return "TIMEOUT"
|
|
out = (r.stdout + r.stderr)
|
|
if "Byte-identical ROM verified." in out:
|
|
return "yes"
|
|
return "NO"
|
|
|
|
|
|
def trace_split(prg, base, undoc):
|
|
"""Re-run the Pass 1 tracer. Returns (insn_count, code_bytes, invalid)."""
|
|
nesdis.set_undocumented(undoc)
|
|
vo = len(prg) - 6
|
|
rst = prg[vo + 2] | (prg[vo + 3] << 8)
|
|
nmi = prg[vo] | (prg[vo + 1] << 8)
|
|
irq = prg[vo + 4] | (prg[vo + 5] << 8)
|
|
insns, invalid, _i, _t, _o = nesdis.trace(prg, base, [rst, nmi, irq])
|
|
nesdis.set_undocumented(False)
|
|
return len(insns), sum(i.length for i in insns.values()), invalid
|
|
|
|
|
|
def main():
|
|
wanted = sys.argv[1:]
|
|
projects = sorted(
|
|
d for d in os.listdir(ROOT)
|
|
if (ROOT / d / "copilot" / "Makefile").is_file()
|
|
and (ROOT / d / "copilot" / "prg.bin" or
|
|
(ROOT / d / "copilot" / "rom.nes").is_file())
|
|
and (wanted == [] or d in wanted)
|
|
)
|
|
|
|
print(f"{'project':30s} {'build':>6} {'cmt':>6} {'committed':>10} "
|
|
f"{'official':>10} {'+undoc':>10} verdict")
|
|
print("-" * 96)
|
|
|
|
flagged, odd_comments = [], []
|
|
for name in projects:
|
|
copilot = ROOT / name / "copilot"
|
|
src = find_source(copilot)
|
|
prg = prg_bytes(copilot)
|
|
base = prg_base(copilot)
|
|
if src is None or prg is None:
|
|
print(f"{name:30s} SKIP")
|
|
continue
|
|
|
|
build = builds_byte_identical(copilot)
|
|
for junk in copilot.glob("*.o"):
|
|
junk.unlink()
|
|
|
|
form = audit_form(src)
|
|
if form != "canonical":
|
|
odd_comments.append((name, form))
|
|
|
|
committed = count_instructions(src)
|
|
off_n, off_b, _ = trace_split(prg, base, False)
|
|
und_n, und_b, _ = trace_split(prg, base, True)
|
|
|
|
if build != "yes":
|
|
verdict = f"BUILD-{build}"
|
|
elif und_n > committed * 1.05 + 16:
|
|
verdict = "TRUNCATED"
|
|
elif off_n < committed * 0.95:
|
|
verdict = "SEEDED"
|
|
else:
|
|
verdict = "OK"
|
|
|
|
if verdict not in ("OK", "SEEDED"):
|
|
flagged.append((name, verdict))
|
|
|
|
print(f"{name:30s} {build:>6} {form[:6]:>6} {committed:>10} "
|
|
f"{off_n:>6}/{off_b:<3} {und_n:>5}/{und_b:<4} {verdict}")
|
|
|
|
print()
|
|
if flagged:
|
|
print("Needs attention:")
|
|
for n, v in flagged:
|
|
print(f" {v:14s} {n}")
|
|
else:
|
|
print("No truncated or unfaithful Pass 1 found.")
|
|
print()
|
|
print("Comment conventions (AGENTS.md wants `canonical`):")
|
|
for n, f in odd_comments:
|
|
print(f" {f:8s} {n}")
|
|
if not odd_comments:
|
|
print(" all canonical")
|
|
return 1 if flagged else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|