Files
disasm/_tools/audit_comment_tool.py
T
tmerritt 2ddaf102a0 Audit comment standardization + new tool (2026-10-19)
Standardized all 18 top-level projects to canonical audit comment format:
  ; CPU:$XXXX PRG:XXXX AA BB

Changes per project:
- CIRCUS: fixed 4,427 comments (CPU:XXXX -> CPU:$XXXX)
- Excitebike (JU): fixed 48 double-semicolon comments (; state $XX  ; CPU:...)
- 9 projects received canonical comments via new audit_comment_tool.py:
  Balloon Fight, Baseball, Bomberman, Burger Time, Clu Clu Land,
  Defender 2, Donkey Kong 3, Donkey Kong Jr., Tennis

New tool: _tools/audit_comment_tool.py
- Uses nesdis.py tracer as ground truth for instruction bytes/addresses
- Parses source while tracking PRG offset, emits canonical comments
- Handles .byte/.word/.dword directives, instruction comments (short format)
- Carves PRG from rom.nes when prg.bin missing

Documentation updates:
- TODO.md: Updated current state, item 3 (tool built), repo conventions, tool list
- AGENTS.md: Added audit_comment_tool.py to _tools/, updated audit convention

Verification: verify_pass1.py reports all 18 projects byte-identical with 'canoni' format.
2026-10-19 09:07:14 -04:00

394 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Audit Comment Tool
Adds canonical `; CPU:$XXXX PRG:XXXX AA BB` comments to disassembly source files.
Uses nesdis.py tracer as ground truth for instruction bytes and addresses.
"""
import sys
import re
import argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import nesdis
# Canonical comment format
CANONICAL_FMT = "; CPU:${cpu:04X} PRG:{prg:04X} {bytes}"
# Line classification patterns
INSTRUCTION_RE = re.compile(r'^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s+(.+?)(?:\s*;.*)?$')
LABEL_RE = re.compile(r'^(\s*)([A-Za-z_][A-Za-z0-9_]*):\s*(?:;.*)?$')
BYTE_RE = re.compile(r'^(\s*)\.byte\s+(.+?)(?:\s*;.*)?$', re.I)
WORD_RE = re.compile(r'^(\s*)\.word\s+(.+?)(?:\s*;.*)?$', re.I)
DWORD_RE = re.compile(r'^(\s*)\.dword\s+(.+?)(?:\s*;.*)?$', re.I)
ORG_RE = re.compile(r'^(\s*)\.org\s+\$([0-9A-Fa-f]+)', re.I)
SEGMENT_RE = re.compile(r'^\.segment\s+"PRG"', re.I)
INCLUDE_RE = re.compile(r'^\.include\s+')
SET_CPU_RE = re.compile(r'^\.setcpu\s+')
EQU_RE = re.compile(r'^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*', re.I)
COMMENT_RE = re.compile(r'^\s*;')
BLANK_RE = re.compile(r'^\s*$')
# Existing address comment in source: ; $C036: A9 E2
SRC_ADDR_RE = re.compile(r';\s*\$([0-9A-Fa-f]{4})\s*:\s*([0-9A-Fa-f\s]+)')
# Existing audit comment patterns to replace - only match canonical format with byte lists at end of line
# Canonical: ; CPU:$XXXX PRG:XXXX AA BB (byte list goes to end of line or before --)
# Source address comments: ; $XXXX: description or ; PRG:$XXXX AA BB description
EXISTING_AUDIT_RE = re.compile(
r'\s*;\s*(?:'
r'CPU[\s:]\$?[\dA-F]+\s+PRG[\s:]\$?[\dA-F]+\s+(?:[\dA-F]{2}\s*)+\s*(?:--.*)?$|' # CPU:$XXXX PRG:XXXX AA BB
r'PRG[\s:]\$?[\dA-F]+\s*\|\s*CPU[\s:]\$?[\dA-F]+\s*\|\s*(?:[\dA-F]{2}\s*)+\s*$|' # PRG $XXXX | CPU $XXXX | AA BB
r'PRG[\s:]\$?[\dA-F]+\s+(?:[\dA-F]{2}\s*)+\s*$|' # PRG:$XXXX AA BB (at end of line)
r'PRG[\s:]\$?[\dA-F]+\s*/\s*CPU[\s:]\$?[\dA-F]+:\s*(?:[\dA-F]{2}\s*)+\s*$|' # PRG $XXXX / CPU $XXXX: AA BB
r'\$[\dA-F]{4}:\s*(?:[\dA-F]{2}\s*)+\s*$|' # $XXXX: AA BB (short form, at end)
r'PRG[\s:]\$?[\dA-F]+\s+Bytes:\s*(?:[\dA-F]{2}\s*)+\s*$|' # PRG:$XXXX Bytes:AA BB
r'ROM\s\$[\dA-F]{6}:\s*(?:[\dA-F]{2}\s*)+\s*$' # ROM $XXXXXX: AA BB
r')'
)
def parse_byte_operands(operand_str):
"""Parse .byte/.word/.dword operands into list of byte values."""
# Strip trailing comment first
operand_str = operand_str.split(';')[0]
operands = []
for part in operand_str.split(','):
part = part.strip()
if not part:
continue
# Handle $XX or #$XX
if part.startswith('$'):
val = int(part[1:], 16)
operands.append(val & 0xFF)
elif part.startswith('#$'):
val = int(part[2:], 16)
operands.append(val & 0xFF)
elif part.isdigit():
operands.append(int(part) & 0xFF)
else:
# Symbol or expression - can't resolve, placeholder
operands.append(None)
return operands
def count_directive_bytes(line):
"""Count bytes generated by .byte/.word/.dword directive."""
m = re.match(r'^\s*\.(byte|word|dword)\s+(.+)', line, re.I)
if not m:
return 0
directive = m.group(1).lower()
operands = parse_byte_operands(m.group(2))
size = {'byte': 1, 'word': 2, 'dword': 4}[directive]
# For .word/.dword, each operand generates 2/4 bytes
# For .byte, each operand generates 1 byte
return len(operands) * size
def build_trace_map(prg_path, base=0xC000, undoc=False):
"""Run tracer and return dict mapping PRG offset -> trace info."""
prg = Path(prg_path).read_bytes()
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, indirect, targets, overlap = nesdis.trace(prg, base, [rst, nmi, irq])
nesdis.set_undocumented(False)
trace_map = {}
for off, insn in insns.items():
cpu = base + off
raw_bytes = prg[off:off + insn.length]
bytes_str = " ".join(f"{b:02X}" for b in raw_bytes)
text = f"{insn.mnemonic} {insn.text}".strip()
trace_map[off] = {
'cpu': cpu,
'prg': off,
'bytes': bytes_str,
'length': insn.length,
'text': text,
'mnemonic': insn.mnemonic,
'operand': insn.text,
'is_code': True
}
# Data regions from scan_orphans - store as 16-byte chunks
regions = nesdis.scan_orphans(prg, base, insns, 1)
for start, length in regions:
for i in range(start, start + length, 16):
chunk = prg[i:min(i + 16, start + length)]
bytes_str = " ".join(f"{b:02X}" for b in chunk)
trace_map[i] = {
'cpu': base + i,
'prg': i,
'bytes': bytes_str,
'length': len(chunk),
'is_data': True
}
return trace_map, prg
def parse_source_for_offsets(source_path, trace_map, base=0xC000):
"""Parse source file, track PRG offset, and generate annotated lines."""
lines = Path(source_path).read_text().split('\n')
annotated = []
prg_offset = 0
in_prg_segment = False
org_set = False
# First pass: find .org to establish base offset
for line in lines:
m = ORG_RE.match(line)
if m:
org_addr = int(m.group(2), 16)
prg_offset = org_addr - base
org_set = True
break
if not org_set:
prg_offset = 0
for line in lines:
stripped = line.strip()
# Skip blank lines and pure comments
if BLANK_RE.match(line) or COMMENT_RE.match(line):
annotated.append((line, None))
continue
# .segment "PRG" - start tracking
if SEGMENT_RE.match(line):
in_prg_segment = True
annotated.append((line, None))
continue
# .org - reset offset
m = ORG_RE.match(line)
if m:
org_addr = int(m.group(2), 16)
prg_offset = org_addr - base
annotated.append((line, None))
continue
# .include, .setcpu, symbol definitions - no bytes
if INCLUDE_RE.match(line) or SET_CPU_RE.match(line) or EQU_RE.match(line):
annotated.append((line, None))
continue
# Label - no bytes
if LABEL_RE.match(stripped):
annotated.append((line, None))
continue
# Check for existing address comment to sync offset: ; $C036: A9 E2
# Only sync if address looks like a CPU address (>= $8000), not a PRG offset
addr_match = SRC_ADDR_RE.search(line)
if addr_match:
cpu_addr = int(addr_match.group(1), 16)
# Only treat as CPU address if >= $8000 (typical PRG mapping)
# PRG offsets in "short" format are typically $0000-$3FFF
if cpu_addr >= 0x8000:
prg_offset = cpu_addr - base
# .byte, .word, .dword - data directives
m = BYTE_RE.match(line) or WORD_RE.match(line) or DWORD_RE.match(line)
if m:
byte_count = count_directive_bytes(line)
# Try to get bytes from existing source comment first
# Formats: ; $XXXX: AA BB or ; PRG:$XXXX AA BB
bytes_str = ""
src_bytes_match = re.search(r';\s*(?:\$([0-9A-Fa-f]{4})\s*:\s*|PRG:\$([0-9A-Fa-f]{4})\s+)([0-9A-Fa-f\s]+)', line)
if src_bytes_match:
# Group 1 or 2 has the address, group 3 has the bytes
bytes_part = src_bytes_match.group(3).strip()
byte_vals = bytes_part.split()
if byte_vals:
bytes_str = " ".join(f"{int(b, 16):02X}" for b in byte_vals)
# Fallback: parse operands if they're hex values
if not bytes_str:
operands = parse_byte_operands(m.group(2))
bytes_str = " ".join(f"{b:02X}" for b in operands if b is not None)
# Fallback to trace map
if not bytes_str:
trace_info = trace_map.get(prg_offset)
if trace_info:
# For data, trace_info has 16-byte chunks; take what we need
tb = trace_info['bytes'].split()
bytes_str = " ".join(tb[:byte_count])
else:
bytes_str = "??"
canonical = CANONICAL_FMT.format(
cpu=base + prg_offset,
prg=prg_offset,
bytes=bytes_str
)
# Remove existing audit comment
clean_line = EXISTING_AUDIT_RE.sub('', line).rstrip()
new_line = f"{clean_line} {canonical}"
annotated.append((new_line, prg_offset))
prg_offset += byte_count
continue
# Instruction
m = INSTRUCTION_RE.match(line)
if m and in_prg_segment:
indent, mnemonic, operand = m.groups()
operand = operand.strip()
# Check for existing address comment with bytes: ; $C036: A9 E2
addr_match = SRC_ADDR_RE.search(line)
bytes_str = "??"
length = 2
if addr_match:
cpu_addr = int(addr_match.group(1), 16)
# Only sync PRG offset if address looks like a CPU address (>= $8000)
# PRG offsets in "short" format are typically $0000-$3FFF
if cpu_addr >= 0x8000:
# Sync PRG offset to this address
prg_offset = cpu_addr - base
# Parse bytes from comment regardless
bytes_part = addr_match.group(2).strip()
byte_vals = bytes_part.split()
if byte_vals:
bytes_str = " ".join(f"{int(b, 16):02X}" for b in byte_vals)
length = len(byte_vals)
else:
bytes_str = "??"
else:
# Look up in trace_map
trace_info = trace_map.get(prg_offset)
if trace_info and trace_info.get('is_code'):
bytes_str = trace_info['bytes']
length = trace_info['length']
else:
# Fallback: estimate length
length = 2
bytes_str = "??"
canonical = CANONICAL_FMT.format(
cpu=base + prg_offset,
prg=prg_offset,
bytes=bytes_str
)
# Remove existing audit comment
clean_line = EXISTING_AUDIT_RE.sub('', line).rstrip()
new_line = f"{clean_line} {canonical}"
annotated.append((new_line, prg_offset))
# Advance offset
prg_offset += length
continue
# Unknown line type - keep as is
annotated.append((line, None))
return annotated
def main():
ap = argparse.ArgumentParser(description="Add canonical audit comments to disassembly source")
ap.add_argument("project", help="Project directory name (e.g., 'Baseball (UE)')")
ap.add_argument("--base", default="0xC000", help="PRG base address")
ap.add_argument("--undoc", action="store_true", help="Include undocumented opcodes in trace")
ap.add_argument("--dry-run", action="store_true", help="Show changes without writing")
args = ap.parse_args()
root = Path(__file__).parent.parent
project_dir = root / args.project
copilot = project_dir / "copilot"
# Find source file
source_file = None
for name in ("prg.s", "main.s"):
if (copilot / name).exists():
source_file = copilot / name
break
if not source_file:
print(f"Error: No prg.s or main.s found in {copilot}")
return 1
# Find PRG binary
prg_file = copilot / "prg.bin"
if not prg_file.exists():
# Try to carve from rom.nes
rom = copilot / "rom.nes"
if rom.exists():
print("Carving PRG from rom.nes...")
rom_data = rom.read_bytes()
# iNES header is 16 bytes
# PRG size = rom[4] * 16384
prg_size = rom_data[4] * 16384
chr_size = rom_data[5] * 8192
has_trainer = bool(rom_data[6] & 4)
prg_start = 16 + (512 if has_trainer else 0)
prg_data = rom_data[prg_start:prg_start + prg_size]
prg_file = copilot / "prg.bin"
prg_file.write_bytes(prg_data)
print(f" Carved {len(prg_data)} bytes to prg.bin")
else:
print(f"Error: No PRG binary found in {copilot}")
return 1
base = int(args.base, 16)
print(f"Building trace map for {args.project}...")
trace_map, prg = build_trace_map(prg_file, base, args.undoc)
print(f" {len(trace_map)} trace entries")
print(f"Parsing source and adding comments...")
annotated = parse_source_for_offsets(source_file, trace_map, base)
# Count changes
old_lines = source_file.read_text().split('\n')
changes = 0
for old, (new_line, _) in zip(old_lines, annotated):
if new_line != old:
changes += 1
print(f" {changes} lines would be modified")
if args.dry_run:
# Show first 50 changes
shown = 0
for i, (old, (new_line, _)) in enumerate(zip(old_lines, annotated)):
if old != new_line and shown < 50:
print(f"Line {i+1}:")
print(f" OLD: {old}")
print(f" NEW: {new_line}")
shown += 1
return 0
# Write back
output = "\n".join(a[0] for a in annotated) + "\n"
source_file.write_text(output)
print(f"Written to {source_file}")
# Verify build
import subprocess
result = subprocess.run(['make'], cwd=copilot, capture_output=True, text=True)
if result.returncode == 0:
print("Build: PASS (byte-identical)")
else:
print("Build: FAIL")
print(result.stdout[-500:])
print(result.stderr[-500:])
return 1
return 0
if __name__ == "__main__":
sys.exit(main())