~90 minutes. Locate a recognizable sprite tile in your ROM's CHR-ROM section; decode its 16 bytes by hand; match it to what you see on screen.
Goal: bridge the gap between "bytes in a file" and "pixels on screen" by finding ONE specific 8x8 tile and decoding it pixel by pixel.
Estimated time: 90 minutes
Prerequisites: lab 2.1 complete (you can read the INES header and know where CHR-ROM starts)
Steps
Step 1: Calculate where CHR-ROM starts (10 minutes)
The CHR-ROM section of your ROM starts after the 16-byte INES header AND after the PRG-ROM. From lab 2.1 you know the PRG-ROM size in 16 KB units.
Calculation:
- CHR-ROM start offset = 16 (header) + (PRG-ROM size in bytes)
- If PRG-ROM is 16 KB, CHR-ROM starts at offset 16 + 16384 = 16400 (which is 0x4010 in hex)
- If PRG-ROM is 32 KB, CHR-ROM starts at offset 16 + 32768 = 32784 (which is 0x8010 in hex)
Calculate for your specific ROM. Write down: "CHR-ROM begins at offset 0x____ (decimal ____)."
If your ROM uses CHR-RAM instead of CHR-ROM (byte 5 of the header was 00), this lab will not work directly. Skip ahead to the "If your ROM has no CHR-ROM" section at the end.
Step 2: Open Mesen's PPU Viewer (10 minutes)
Launch Mesen. Load your ROM. Tools → PPU Viewer. Click the "Pattern Tables" tab.
You see two pattern tables (typically labeled "Pattern Table 0" and "Pattern Table 1"). Each contains 256 tiles, laid out in a 16×16 grid. Each tile is 8 pixels by 8 pixels.
Pick ONE tile you can recognize: maybe the player character's head; an enemy face; a specific letter from the title screen; a heart icon; whatever you see clearly on screen and can also find in the pattern table.
Hover your mouse over the tile in the PPU Viewer. Mesen typically shows the tile index (a number 0-255) and may show the byte offset in the ROM file.
Write down: "I picked tile index ___ from pattern table ___. It looks like ___ (describe what the tile shows)."
Step 3: Calculate the byte offset for your tile (10 minutes)
Each tile is 16 bytes (8 bytes for the "low bitplane" and 8 bytes for the "high bitplane"). The math:
- Tile offset within CHR-ROM = tile index × 16
- Total offset in file = CHR-ROM start offset + (tile index × 16) + (4096 if your tile is in pattern table 1, since pattern table 1 starts 4 KB into CHR-ROM)
Example: a tile at index 65 in pattern table 0, with PRG-ROM 16 KB:
- CHR-ROM start = 0x4010
- Tile offset within CHR-ROM = 65 × 16 = 1040 = 0x410
- File offset = 0x4010 + 0x410 = 0x4420
Calculate for your tile. Write down: "My tile starts at file offset 0x____."
Step 4: Read the 16 bytes in your hex editor (15 minutes)
Open your ROM in your hex editor. Navigate to the file offset you just calculated.
Read the 16 bytes starting there. Write them down in two rows of 8:
Bytes 0-7 (low bitplane): __ __ __ __ __ __ __ __
Bytes 8-15 (high bitplane): __ __ __ __ __ __ __ __
Step 5: Decode the 8x8 tile from the 16 bytes (25 minutes)
NES tile encoding works like this. Each pixel uses 2 bits of color information (so each pixel is 0, 1, 2, or 3, an index into a 4-color palette). The 2 bits for a single pixel are split across two bytes:
- The low bit of each pixel comes from the FIRST 8 bytes (the low bitplane)
- The high bit of each pixel comes from the SECOND 8 bytes (the high bitplane)
To decode pixel (column c, row r) where c and r run 0-7:
- Take byte r of the low bitplane (bytes 0-7)
- Take byte r of the high bitplane (bytes 8-15)
- Look at bit (7 - c) of each. (MSB-first: column 0 is the leftmost, so bit 7)
- Combine: low_bit + (high_bit × 2) = pixel color index (0, 1, 2, or 3)
Try this for a 2x2 corner of the tile (top-left 4 pixels). Compute the color index for each.
Then either: decode the whole 8x8 by hand (slow but instructive), OR sketch it on graph paper and mark which pixels are 0 vs. 1 vs. 2 vs. 3.
Step 6: Compare to Mesen's view (10 minutes)
In Mesen's PPU Viewer, look at your tile. Compare the pixel pattern you decoded to the pixel pattern Mesen displays. They should match.
If they do not match, retrace your decoding: most likely the bit ordering tripped you up (column 0 is bit 7, not bit 0).
Step 7: Journal what you did (10 minutes)
Open ~/spk-101/journal/lab-2-2-notes.md. Write:
- The tile you picked and why
- The byte offset of the tile
- The raw 16 bytes
- Your hand-decoded pixel pattern (sketch or grid)
- Mesen's view of the same tile
- A reflection: what surprised you about how compact NES sprite encoding is? (8x8 pixels with 4 colors = 16 bytes; vs a modern PNG header alone is 33 bytes for ZERO pixels)
Expected output
- One sprite tile decoded by hand from raw bytes
- A pixel-by-pixel match (or near-match) with Mesen's view
- A journal entry walking through the decoding
If your ROM has no CHR-ROM (uses CHR-RAM)
Some homebrew ROMs use CHR-RAM rather than CHR-ROM (byte 5 of the INES header is 0). In this case the graphics live somewhere in PRG-ROM and the game loads them into CHR-RAM at startup.
For this lab, the easier path is to:
- Use Mesen's PPU Viewer to pick a tile and see its raw bytes (the viewer shows hex for each tile)
- Note that for this ROM you cannot directly find the bytes in the file at a fixed offset; they are decompressed at runtime
- Move on; week 4's labs work fine with CHR-RAM ROMs
Common pitfalls
- Forgetting the 16-byte INES header offset: file offset = 16 + (PRG-ROM size) + (tile offset within CHR-ROM). Forgetting the +16 means you read garbage
- Off-by-one on bit ordering: NES tiles are MSB-first per row. Column 0 (leftmost pixel) is bit 7, not bit 0. Column 7 (rightmost) is bit 0
- Confusing low and high bitplanes: the first 8 bytes are the LOW bitplane (contributes the low bit of each pixel); the second 8 bytes are the HIGH bitplane
Stretch (optional)
- Pick a second tile and decode it too
- Try to find the 8x8 tile that makes the "0" character in the title screen score display (every game has a digit set somewhere in pattern memory)
- Read the NESdev wiki page on CHR-ROM at
https://www.nesdev.org/wiki/PPU_pattern_tables
Lab 2.2 v0.1. The bridge between "bytes" and "pixels" in your head. This is the most concrete moment of week 2.