#!/usr/bin/env python3 import argparse import math from pathlib import Path from PIL import Image, ImageDraw, ImageFont # ============================================================================= # NES CHR constants # ============================================================================= CHR_BANK_SIZE = 0x2000 PATTERN_TABLE_SIZE = 0x1000 TILE_SIZE_BYTES = 16 TILE_WIDTH = 8 TILE_HEIGHT = 8 TILES_PER_PATTERN_TABLE = 256 # ============================================================================= # NES RGB palette # # Common RGB approximation of the NES 64-color system palette. # CHR-ROM itself does NOT store these colors. CHR pixels contain values 0-3, # which are mapped through NES palette RAM into one of these 64 colors. # ============================================================================= NES_RGB = [ # $00-$0F (84, 84, 84), (0, 30, 116), (8, 16, 144), (48, 0, 136), (68, 0, 100), (92, 0, 48), (84, 4, 0), (60, 24, 0), (32, 42, 0), (8, 58, 0), (0, 64, 0), (0, 60, 0), (0, 50, 60), (0, 0, 0), (0, 0, 0), (0, 0, 0), # $10-$1F (152, 150, 152), (8, 76, 196), (48, 50, 236), (92, 30, 228), (136, 20, 176), (160, 20, 100), (152, 34, 32), (120, 60, 0), (84, 90, 0), (40, 114, 0), (8, 124, 0), (0, 118, 40), (0, 102, 120), (0, 0, 0), (0, 0, 0), (0, 0, 0), # $20-$2F (236, 238, 236), (76, 154, 236), (120, 124, 236), (176, 98, 236), (228, 84, 236), (236, 88, 180), (236, 106, 100), (212, 136, 32), (160, 170, 0), (116, 196, 0), (76, 208, 32), (56, 204, 108), (56, 180, 204), (60, 60, 60), (0, 0, 0), (0, 0, 0), # $30-$3F (236, 238, 236), (168, 204, 236), (188, 188, 236), (212, 178, 236), (236, 174, 236), (236, 174, 212), (236, 180, 176), (228, 196, 144), (204, 210, 120), (180, 222, 120), (168, 226, 144), (152, 226, 180), (160, 214, 228), (160, 162, 160), (0, 0, 0), (0, 0, 0), ] GRAYSCALE = [ (0, 0, 0), (85, 85, 85), (170, 170, 170), (255, 255, 255), ] # ============================================================================= # CHR decoder # ============================================================================= def decode_tile(chr_data, tile_index): """ Decode one NES 8x8 2bpp CHR tile. Each tile occupies 16 bytes: bytes 0-7 bitplane 0 bytes 8-15 bitplane 1 Each resulting pixel has a value from 0 through 3. """ offset = tile_index * TILE_SIZE_BYTES tile = chr_data[offset:offset + TILE_SIZE_BYTES] if len(tile) != TILE_SIZE_BYTES: raise ValueError( f"Incomplete CHR tile ${tile_index:03X}" ) pixels = [] for y in range(TILE_HEIGHT): plane0 = tile[y] plane1 = tile[y + 8] row = [] for x in range(TILE_WIDTH): bit = 7 - x low = (plane0 >> bit) & 1 high = (plane1 >> bit) & 1 pixel = low | (high << 1) row.append(pixel) pixels.append(row) return pixels # ============================================================================= # Palette handling # ============================================================================= def nes_palette_to_rgb(indices): """ Convert four NES palette indices into RGB colors. """ if len(indices) != 4: raise ValueError( "Palette must contain exactly four NES colors" ) return [ NES_RGB[index & 0x3F] for index in indices ] def parse_hex_byte(value): """ Parse a command-line hexadecimal byte. Accepted examples: 0F FF 0x20 $26 """ value = value.strip() if value.startswith("$"): value = value[1:] if value.lower().startswith("0x"): value = value[2:] number = int(value, 16) if not 0 <= number <= 0xFF: raise argparse.ArgumentTypeError( f"Value out of byte range: {value}" ) return number # ============================================================================= # Tile rendering # ============================================================================= def make_tile_image(tile, scale, colors=None): """ Render one decoded 8x8 tile. colors[0..3] maps CHR pixel values 0..3 to RGB colors. """ if colors is None: colors = GRAYSCALE image = Image.new( "RGB", (TILE_WIDTH, TILE_HEIGHT), colors[0] ) pixels = image.load() for y in range(TILE_HEIGHT): for x in range(TILE_WIDTH): pixels[x, y] = colors[tile[y][x]] if scale != 1: image = image.resize( ( TILE_WIDTH * scale, TILE_HEIGHT * scale ), Image.Resampling.NEAREST ) return image # ============================================================================= # Clean sprite sheet # ============================================================================= def make_clean_sheet( chr_data, columns=16, scale=4, colors=None ): """ Render CHR tiles as a compact sprite sheet. """ tile_count = len(chr_data) // TILE_SIZE_BYTES rows = math.ceil(tile_count / columns) rendered_width = TILE_WIDTH * scale rendered_height = TILE_HEIGHT * scale sheet = Image.new( "RGB", ( columns * rendered_width, rows * rendered_height ), (0, 0, 0) ) for tile_index in range(tile_count): tile = decode_tile( chr_data, tile_index ) tile_image = make_tile_image( tile, scale, colors ) column = tile_index % columns row = tile_index // columns x = column * rendered_width y = row * rendered_height sheet.paste( tile_image, (x, y) ) return sheet # ============================================================================= # Labeled sprite sheet # ============================================================================= def make_labeled_sheet( chr_data, columns=16, tile_scale=4, label_height=12, tile_base=0, colors=None ): """ Render a debugging sheet with each tile labeled by hexadecimal tile ID. """ tile_count = len(chr_data) // TILE_SIZE_BYTES rows = math.ceil(tile_count / columns) rendered_width = TILE_WIDTH * tile_scale rendered_height = TILE_HEIGHT * tile_scale cell_width = max( rendered_width + 4, 40 ) cell_height = ( rendered_height + label_height + 6 ) sheet = Image.new( "RGB", ( columns * cell_width, rows * cell_height ), (24, 24, 24) ) draw = ImageDraw.Draw(sheet) font = ImageFont.load_default() for tile_index in range(tile_count): tile = decode_tile( chr_data, tile_index ) tile_image = make_tile_image( tile, tile_scale, colors ) column = tile_index % columns row = tile_index // columns cell_x = column * cell_width cell_y = row * cell_height tile_x = ( cell_x + (cell_width - rendered_width) // 2 ) tile_y = cell_y + 2 sheet.paste( tile_image, (tile_x, tile_y) ) display_index = tile_base + tile_index label = f"{display_index:03X}" bbox = draw.textbbox( (0, 0), label, font=font ) text_width = bbox[2] - bbox[0] text_x = ( cell_x + (cell_width - text_width) // 2 ) text_y = ( cell_y + rendered_height + 4 ) draw.text( (text_x, text_y), label, fill=(255, 255, 255), font=font ) return sheet # ============================================================================= # iNES extraction # ============================================================================= def extract_chr_banks_from_ines(rom): """ Extract every 8 KiB CHR-ROM bank from an iNES ROM. """ if len(rom) < 16: raise ValueError( "File is too small to contain an iNES header" ) if rom[:4] != b"NES\x1A": raise ValueError( "Input does not contain a valid iNES header" ) prg_banks = rom[4] chr_banks = rom[5] flags6 = rom[6] if chr_banks == 0: raise ValueError( "ROM contains no CHR-ROM. " "This cartridge may use CHR-RAM." ) trainer_size = ( 512 if flags6 & 0x04 else 0 ) prg_size = ( prg_banks * 0x4000 ) chr_offset = ( 16 + trainer_size + prg_size ) banks = [] for bank_index in range(chr_banks): start = ( chr_offset + bank_index * CHR_BANK_SIZE ) end = ( start + CHR_BANK_SIZE ) if end > len(rom): raise ValueError( f"ROM truncated while reading " f"CHR bank ${bank_index:02X}" ) banks.append( rom[start:end] ) return banks # ============================================================================= # Raw CHR extraction # ============================================================================= def extract_raw_chr_banks(data): """ Split raw CHR data into 8 KiB banks. """ if len(data) % CHR_BANK_SIZE != 0: raise ValueError( "Raw CHR data size must be a multiple " f"of ${CHR_BANK_SIZE:04X} bytes" ) banks = [] for offset in range( 0, len(data), CHR_BANK_SIZE ): banks.append( data[ offset: offset + CHR_BANK_SIZE ] ) return banks # ============================================================================= # GIF output # ============================================================================= def save_gif(image, filename): """ Save a static GIF. """ image.save( filename, format="GIF", optimize=False ) print( f" {filename} " f"({image.width}x{image.height})" ) # ============================================================================= # Pattern table generation # ============================================================================= def process_pattern_table( pattern_data, output_prefix, bank_index, table_index, scale, columns, colors, suffix="" ): """ Produce clean and labeled sheets for a 4 KiB pattern table. """ tile_base = ( table_index * 0x100 ) base_name = ( f"{output_prefix}" f"_chr{bank_index:02X}" f"_pt{table_index}" f"{suffix}" ) clean = make_clean_sheet( pattern_data, columns=columns, scale=scale, colors=colors ) save_gif( clean, f"{base_name}.gif" ) labeled = make_labeled_sheet( pattern_data, columns=columns, tile_scale=scale, tile_base=tile_base, colors=colors ) save_gif( labeled, f"{base_name}_labels.gif" ) # ============================================================================= # Full CHR bank rendering # ============================================================================= def process_chr_bank( bank, bank_index, output_prefix, scale, columns, colors, suffix="" ): """ Generate: full 8 KiB CHR bank pattern table 0 pattern table 1 labeled pattern table 0 labeled pattern table 1 """ full_name = ( f"{output_prefix}" f"_chr{bank_index:02X}" f"_full" f"{suffix}" f".gif" ) full_sheet = make_clean_sheet( bank, columns=columns, scale=scale, colors=colors ) save_gif( full_sheet, full_name ) pattern0 = bank[ 0x0000: 0x1000 ] pattern1 = bank[ 0x1000: 0x2000 ] process_pattern_table( pattern0, output_prefix, bank_index, 0, scale, columns, colors, suffix ) process_pattern_table( pattern1, output_prefix, bank_index, 1, scale, columns, colors, suffix ) # ============================================================================= # Full 32-byte palette RAM mode # ============================================================================= def process_palette_ram( banks, palette_ram, output_prefix, scale, columns ): """ Render each CHR bank through all four background palettes and all four sprite palettes contained in a 32-byte NES palette-RAM image. """ if len(palette_ram) != 32: raise ValueError( "Palette RAM must contain exactly 32 bytes" ) print() print("Generating background palette sheets") for palette_number in range(4): start = palette_number * 4 palette_indices = ( palette_ram[ start: start + 4 ] ) colors = nes_palette_to_rgb( palette_indices ) suffix = ( f"_bg{palette_number}" ) print() print( f" BG{palette_number}: " + " ".join( f"${value:02X}" for value in palette_indices ) ) for bank_index, bank in enumerate(banks): process_chr_bank( bank, bank_index, output_prefix, scale, columns, colors, suffix ) print() print("Generating sprite palette sheets") for palette_number in range(4): start = ( 0x10 + palette_number * 4 ) palette_indices = ( palette_ram[ start: start + 4 ] ) colors = nes_palette_to_rgb( palette_indices ) suffix = ( f"_sp{palette_number}" ) print() print( f" SP{palette_number}: " + " ".join( f"${value:02X}" for value in palette_indices ) ) for bank_index, bank in enumerate(banks): process_chr_bank( bank, bank_index, output_prefix, scale, columns, colors, suffix ) # ============================================================================= # Main # ============================================================================= def main(): parser = argparse.ArgumentParser( description=( "NES CHR-ROM GIF visualizer " "for reverse engineering" ) ) parser.add_argument( "input", help="iNES ROM or raw CHR binary" ) parser.add_argument( "--output-prefix", default=None, help="Output filename prefix" ) parser.add_argument( "--raw", action="store_true", help="Treat input as raw CHR data" ) parser.add_argument( "--scale", type=int, default=4, help="Tile scale factor (default: 4)" ) parser.add_argument( "--columns", type=int, default=16, help="Tiles per row (default: 16)" ) parser.add_argument( "--palette", nargs=4, type=parse_hex_byte, metavar=("C0", "C1", "C2", "C3"), help=( "Render using one NES four-color palette. " "Example: --palette 0F 20 0F 06" ) ) parser.add_argument( "--palette-ram", nargs=32, type=parse_hex_byte, metavar="XX", help=( "Complete 32-byte NES palette RAM image. " "Generates output for all four BG and " "all four sprite palettes." ) ) args = parser.parse_args() if args.scale < 1: parser.error( "--scale must be at least 1" ) if args.columns < 1: parser.error( "--columns must be at least 1" ) if ( args.palette is not None and args.palette_ram is not None ): parser.error( "--palette and --palette-ram " "cannot be used together" ) input_path = Path( args.input ) data = input_path.read_bytes() if args.output_prefix: output_prefix = ( args.output_prefix ) else: output_prefix = ( input_path.stem ) # ------------------------------------------------------------------------- # Extract CHR # ------------------------------------------------------------------------- if args.raw: banks = extract_raw_chr_banks( data ) elif data[:4] == b"NES\x1A": banks = extract_chr_banks_from_ines( data ) else: print( "No iNES header detected. " "Treating input as raw CHR." ) banks = extract_raw_chr_banks( data ) print( f"Input: {input_path}" ) print( f"CHR banks: {len(banks)}" ) # ------------------------------------------------------------------------- # Full palette RAM mode # ------------------------------------------------------------------------- if args.palette_ram is not None: process_palette_ram( banks, args.palette_ram, output_prefix, args.scale, args.columns ) return # ------------------------------------------------------------------------- # Single-palette / grayscale mode # ------------------------------------------------------------------------- if args.palette is not None: colors = nes_palette_to_rgb( args.palette ) suffix = "_color" print( "Palette: " + " ".join( f"${value:02X}" for value in args.palette ) ) else: colors = GRAYSCALE suffix = "" print( "Palette: grayscale CHR values" ) for bank_index, bank in enumerate(banks): print() print( f"CHR bank ${bank_index:02X}" ) print( f" Size: ${len(bank):04X}" ) print( f" Tiles: " f"{len(bank) // TILE_SIZE_BYTES}" ) process_chr_bank( bank, bank_index, output_prefix, args.scale, args.columns, colors, suffix ) if __name__ == "__main__": main()