Baseball Clu Clu Land Excitebike Pac-Man (Namco) Tennis Balloon Fight Bomberman (U) Burger Time (U) Defender 2 (U)
176 lines
5.3 KiB
Python
176 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Split an iNES or NES 2.0 ROM into header, trainer, PRG, and CHR files."""
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HEADER_SIZE = 16
|
|
TRAINER_SIZE = 512
|
|
PRG_BANK_SIZE = 16 * 1024
|
|
CHR_BANK_SIZE = 8 * 1024
|
|
|
|
|
|
def decode_nes2_size(lsb: int, ms_nibble: int, unit: int, kind: str) -> int:
|
|
"""Return the NES 2.0 ROM size in bytes."""
|
|
if ms_nibble != 0x0F:
|
|
return ((ms_nibble << 8) | lsb) * unit
|
|
|
|
# NES 2.0 exponent/multiplier encoding:
|
|
# size = 2^E * (M * 2 + 1), where E = lsb >> 2 and M = lsb & 3.
|
|
exponent = lsb >> 2
|
|
multiplier = (lsb & 0x03) * 2 + 1
|
|
size = (1 << exponent) * multiplier
|
|
if size <= 0:
|
|
raise ValueError(f"Invalid NES 2.0 {kind} size")
|
|
return size
|
|
|
|
|
|
def parse_header(data: bytes) -> dict:
|
|
if len(data) < HEADER_SIZE:
|
|
raise ValueError("File is too small to contain a 16-byte NES header")
|
|
|
|
if data[:4] != b"NES\x1a":
|
|
raise ValueError("Not an iNES/NES 2.0 ROM (missing NES 1A signature)")
|
|
|
|
flags6 = data[6]
|
|
flags7 = data[7]
|
|
nes2 = (flags7 & 0x0C) == 0x08
|
|
has_trainer = bool(flags6 & 0x04)
|
|
|
|
mapper = (flags6 >> 4) | (flags7 & 0xF0)
|
|
submapper = None
|
|
|
|
if nes2:
|
|
mapper |= (data[8] & 0x0F) << 8
|
|
submapper = data[8] >> 4
|
|
prg_size = decode_nes2_size(data[4], data[9] & 0x0F, PRG_BANK_SIZE, "PRG-ROM")
|
|
chr_size = decode_nes2_size(data[5], (data[9] >> 4) & 0x0F, CHR_BANK_SIZE, "CHR-ROM")
|
|
else:
|
|
prg_size = data[4] * PRG_BANK_SIZE
|
|
chr_size = data[5] * CHR_BANK_SIZE
|
|
|
|
return {
|
|
"format": "NES 2.0" if nes2 else "iNES",
|
|
"mapper": mapper,
|
|
"submapper": submapper,
|
|
"has_trainer": has_trainer,
|
|
"prg_size": prg_size,
|
|
"chr_size": chr_size,
|
|
}
|
|
|
|
|
|
def write_file(path: Path, data: bytes) -> None:
|
|
path.write_bytes(data)
|
|
print(f" {path.name:<16} {len(data):>8} bytes")
|
|
|
|
|
|
def split_fixed_banks(
|
|
data: bytes,
|
|
offset: int,
|
|
total_size: int,
|
|
bank_size: int,
|
|
prefix: str,
|
|
output_dir: Path,
|
|
) -> int:
|
|
"""Split a ROM region into fixed-size banks and return the new offset."""
|
|
end = offset + total_size
|
|
if end > len(data):
|
|
raise ValueError(
|
|
f"ROM is truncated while reading {prefix.upper()}: "
|
|
f"header requires {total_size} bytes at file offset 0x{offset:X}, "
|
|
f"but only {len(data) - offset} bytes remain"
|
|
)
|
|
|
|
region = data[offset:end]
|
|
|
|
# Normal iNES sizes divide exactly into these units. NES 2.0's
|
|
# exponent/multiplier encoding may not, so preserve any final partial bank.
|
|
for bank_no, start in enumerate(range(0, total_size, bank_size)):
|
|
chunk = region[start:start + bank_size]
|
|
path = output_dir / f"{prefix}_{bank_no:02X}.bin"
|
|
write_file(path, chunk)
|
|
|
|
return end
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Split an NES ROM into PRG-ROM and CHR-ROM banks."
|
|
)
|
|
parser.add_argument("rom", type=Path, help="input .nes ROM")
|
|
parser.add_argument(
|
|
"-o", "--output", type=Path,
|
|
help="output directory (default: <romname>_split)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if not args.rom.is_file():
|
|
print(f"Error: ROM not found: {args.rom}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
data = args.rom.read_bytes()
|
|
info = parse_header(data)
|
|
|
|
output_dir = args.output
|
|
if output_dir is None:
|
|
output_dir = args.rom.parent / f"{args.rom.stem}_split"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"ROM: {args.rom}")
|
|
print(f"Size: {len(data)} bytes")
|
|
print(f"Format: {info['format']}")
|
|
print(f"Mapper: {info['mapper']}")
|
|
if info["submapper"] is not None:
|
|
print(f"Submapper: {info['submapper']}")
|
|
print(f"Trainer: {'yes' if info['has_trainer'] else 'no'}")
|
|
print(f"PRG-ROM: {info['prg_size']} bytes ({info['prg_size'] / 1024:g} KiB)")
|
|
print(f"CHR-ROM: {info['chr_size']} bytes ({info['chr_size'] / 1024:g} KiB)")
|
|
print(f"Output: {output_dir}")
|
|
print()
|
|
|
|
print("Header:")
|
|
write_file(output_dir / "header.bin", data[:HEADER_SIZE])
|
|
|
|
offset = HEADER_SIZE
|
|
|
|
if info["has_trainer"]:
|
|
trainer_end = offset + TRAINER_SIZE
|
|
if trainer_end > len(data):
|
|
raise ValueError("ROM is truncated inside the 512-byte trainer")
|
|
print("\nTrainer:")
|
|
write_file(output_dir / "trainer.bin", data[offset:trainer_end])
|
|
offset = trainer_end
|
|
|
|
print("\nPRG-ROM:")
|
|
if info["prg_size"]:
|
|
offset = split_fixed_banks(
|
|
data, offset, info["prg_size"], PRG_BANK_SIZE, "prg", output_dir
|
|
)
|
|
else:
|
|
print(" none")
|
|
|
|
print("\nCHR-ROM:")
|
|
if info["chr_size"]:
|
|
offset = split_fixed_banks(
|
|
data, offset, info["chr_size"], CHR_BANK_SIZE, "chr", output_dir
|
|
)
|
|
else:
|
|
print(" none (cartridge may use CHR-RAM)")
|
|
|
|
if offset < len(data):
|
|
print("\nTrailing data:")
|
|
write_file(output_dir / "extra.bin", data[offset:])
|
|
|
|
print("\nDone.")
|
|
return 0
|
|
|
|
except (OSError, ValueError) as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|