diff --git a/STATE.md b/STATE.md index 59fc4d7..2b81d6d 100644 --- a/STATE.md +++ b/STATE.md @@ -52,14 +52,21 @@ REWEIGHTED ROADMAP: priority is now building the rest of the NES hardware so gam Phases in order: 1. NMI/IRQ servicing - DONE (see Done section). cpu_interrupts.nes verification deferred to the last milestone (needs APU frame-counter IRQ) -2. PPU-1 rendering core (new src/ppu.rs): registers $2000-$2007 routed in bus (currently stubbed 0), VRAM (nametables, pattern tables, palettes), background rendering into 256x240 framebuffer of palette indices, $2002 status with vblank flag, mirroring from cart, vblank raises NMI. Acceptance: SMB title screen renders, verified headless by framebuffer dump. NEXT +2. PPU-1 rendering core (new src/ppu.rs): registers $2000-$2007 routed in bus (currently stubbed 0), VRAM (nametables, pattern tables, palettes), background rendering into 256x240 framebuffer of palette indices, $2002 status with vblank flag, mirroring from cart, vblank raises NMI. Acceptance: SMB title screen renders, verified headless by framebuffer dump. NEXT. Confirmed decisions: full dot-accurate scanline model (sprites in PPU-2 need cycle timing, MMC3 needs scanline IRQ), scroll registers $2005/$2006 correct now (v/t/x/w loopy system), Cartridge passed as a parameter to read/write/tick (no Rc/RefCell). Build steps: + + 2a. DONE - palette.rs (64-color 2C02 SYSTEM_PALETTE in index order + index_to_rgb with & 0x3F mask) and render.rs (trait Renderer with present(framebuffer, width, height) + PpmRenderer + pure ppm_bytes for testability). 4 unit tests pass (known palette values, index masking, PPM header, per-pixel RGB routing). Note: 2C02 chosen over composite palettes - we render the raw PPU output. src/ppu.rs exists as an empty placeholder + 2b. Ppu struct + registers $2000-$2007 + VRAM routing ($0000-$1FFF = cart CHR via cart.read_chr/write_chr, $2000-$2FFF = nametables in vram[0x800] with cart.mirroring(), $3F00-$3FFF = palette[32] with $3F10-$3F1F mirror) + bus wiring ($2000-$3FFF reads/writes via disjoint-field borrows). Verify with a debug harness writing $2006/$2007 and reading back. NEXT + 2c. Frame timing + vblank + NMI: 341 dots/scanline, 262 scanlines/frame, 3 PPU dots per CPU cycle. tick(cycles) advances cycles*3 dots. Scanline 241 dot 0 sets vblank flag + nmi_pending. Frame loop bridges: take_nmi() -> cpu.set_nmi(). Verify $2002 flag toggles and NMI fires + 2d. Static background render at scroll 0: nametable -> attribute -> pattern -> palette into 256x240 framebuffer. May show messy SMB output before scroll lands - expected + 2e. Scroll registers v/t/x/w (loopy system) + the dot-by-dot background pipeline: shift registers (bg_pattern_lo/hi 16-bit, bg_attr 16-bit, tile_latch, attr_latch), 4 fetch stages per 8-dot group (nametable, attribute, pattern low, pattern high), coarse X increment every 8 dots with nametable flip, coarse Y/fine Y wrap at scanline end. The densest code in the project + 2f. Acceptance: SMB title screen renders recognizable, dumped to PPM 3. PPU-2 sprites + scrolling + $4014 OAM DMA: makes SMB actually playable 4. GUI (egui + winit, first external deps): display framebuffer, 60fps loop, keyboard -> controller ($4016/$4017). First "games running on screen" moment 5. Mappers: 2 (UNROM), 7 (AOROM) simple; 4 (MMC3) complex - includes scanline IRQ counter required for SMB3 status bar. Verified by running the real backups 6. APU audio: 5 channels, frame counter -> IRQ, mixing. Verified with SMB 7. LAST: blargg full suite (screen output becomes observable) + illegal-opcode phase for the full 8991-line nestest pass -Design note for PPU: PPU needs &mut Cartridge (CHR via read_chr/write_chr, mirroring) and &mut Cpu (raise NMI). Plan a main-frame loop that owns bus + cpu and drives both, rather than PPU holding references (borrow-checker friction). Framebuffer = palette indices (1 byte/pixel), RGB conversion happens in the GUI. +Design note for PPU: PPU takes &mut Cartridge as a parameter to read/write/tick (CHR via cart.read_chr/write_chr, mirroring via cart.mirroring). Frame loop owns bus + cpu + renderer and drives all three - loop bridges NMI via ppu.take_nmi() -> cpu.set_nmi(), so neither PPU nor bus holds a &mut Cpu. Framebuffer = palette indices (1 byte/pixel), RGB conversion happens in the Renderer (PPM now, GUI later). Blargg/illegal background: ~76 illegal opcodes in the nestest log (23 undocumented NOPs, SLO/RLA/SRE/RRA, LAX/SAX, DCP/ISC, EB=SBC alias). Reference: nesdev wiki. Not needed for the backup library but wanted for the full log pass. diff --git a/src/main.rs b/src/main.rs index bc14733..48282f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,9 @@ mod cartridge; mod bus; mod cpu; mod mapper; +mod palette; +mod render; +mod ppu; use crate::bus::{Bus, NesBus}; use crate::cartridge::Cartridge; diff --git a/src/palette.rs b/src/palette.rs new file mode 100644 index 0000000..421d0a5 --- /dev/null +++ b/src/palette.rs @@ -0,0 +1,46 @@ +pub const SYSTEM_PALETTE: [(u8, u8, u8); 64] = [ + (0x5D, 0x5D, 0x5D), (0x00, 0x00, 0x80), (0x1D, 0x00, 0x96), (0x49, 0x00, 0x8C), // 00-03 + (0x72, 0x00, 0x66), (0x89, 0x00, 0x21), (0x84, 0x12, 0x00), (0x66, 0x2E, 0x00), // 04-07 + (0x3A, 0x49, 0x00), (0x00, 0x56, 0x00), (0x00, 0x50, 0x18), (0x00, 0x3E, 0x37), // 08-0B + (0x00, 0x2B, 0x55), (0x00, 0x2B, 0x66), (0x00, 0x24, 0x54), (0x00, 0x16, 0x2E), // 0C-0F + (0xBE, 0xBE, 0xBE), (0x00, 0x24, 0xF6), (0x51, 0x14, 0xE7), (0x93, 0x0C, 0xD4), // 10-13 + (0xBF, 0x0B, 0x9F), (0xD8, 0x20, 0x4C), (0xD0, 0x46, 0x00), (0xA8, 0x6A, 0x00), // 14-17 + (0x73, 0x8A, 0x00), (0x13, 0xA5, 0x00), (0x00, 0x9A, 0x32), (0x00, 0x7B, 0x67), // 18-1B + (0x00, 0x62, 0x8D), (0x00, 0x5C, 0xA1), (0x00, 0x4F, 0x85), (0x00, 0x37, 0x50), // 1C-1F + (0xFF, 0xFF, 0xFF), (0x64, 0x8D, 0xFF), (0xA0, 0x70, 0xFF), (0xD8, 0x5A, 0xFF), // 20-23 + (0xFF, 0x4E, 0xFF), (0xFF, 0x63, 0x90), (0xFF, 0x82, 0x3C), (0xFF, 0xA0, 0x28), // 24-27 + (0xCD, 0xB8, 0x19), (0x72, 0xD4, 0x00), (0x49, 0xCA, 0x6B), (0x27, 0xB0, 0x9B), // 28-2B + (0x2D, 0x95, 0xC7), (0x3A, 0x8B, 0xE0), (0x27, 0x76, 0xB7), (0x00, 0x5B, 0x83), // 2C-2F + (0xFF, 0xFF, 0xFF), (0xB1, 0xC7, 0xFF), (0xCC, 0xB6, 0xFF), (0xE2, 0xA8, 0xFF), // 30-33 + (0xFF, 0x9F, 0xFF), (0xFF, 0xA9, 0xBE), (0xFF, 0xB4, 0x8C), (0xFF, 0xBE, 0x7A), // 34-37 + (0xEC, 0xC6, 0x6F), (0xB8, 0xD6, 0x5E), (0x9F, 0xD2, 0x8A), (0x8E, 0xC5, 0xA6), // 38-3B + (0x92, 0xB9, 0xBF), (0x99, 0xB4, 0xC9), (0x86, 0xA6, 0xB1), (0x66, 0x87, 0x96), // 3C-3F +]; // as per the 2C02 color palette + +pub fn index_to_rgb(index: u8) -> (u8, u8, u8) { + SYSTEM_PALETTE[(index & 0x3F) as usize] +} + +// Tests +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_color_values() { + // spot-check the 2C02 table: a few entries across the 4 rows + assert_eq!(SYSTEM_PALETTE[0x00], (0x5D, 0x5D, 0x5D)); // dark gray + assert_eq!(SYSTEM_PALETTE[0x0F], (0x00, 0x16, 0x2E)); // dark blue + assert_eq!(SYSTEM_PALETTE[0x10], (0xBE, 0xBE, 0xBE)); // medium gray + assert_eq!(SYSTEM_PALETTE[0x20], (0xFF, 0xFF, 0xFF)); // white + assert_eq!(SYSTEM_PALETTE[0x21], (0x64, 0x8D, 0xFF)); // light blue + assert_eq!(SYSTEM_PALETTE[0x3F], (0x66, 0x87, 0x96)); // dark muted blue + } + + #[test] + fn index_to_rgb_masks_to_six_bits() { + // palette indices are 6-bit; anything >= 0x40 wraps around safely + assert_eq!(index_to_rgb(0x00), index_to_rgb(0x40)); + assert_eq!(index_to_rgb(0x21), index_to_rgb(0x61)); + } +} \ No newline at end of file diff --git a/src/ppu.rs b/src/ppu.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..468ce33 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,64 @@ +use crate::palette::index_to_rgb; + +pub trait Renderer { + fn present(&mut self, framebuffer: &[u8], width: u16, height: u16); +} + +pub struct PpmRenderer { + dir: String, + frame: u32, +} + + +impl PpmRenderer { + pub fn new(dir: &str) -> Self { + Self { dir: dir.to_string(), frame: 0 } + } +} + + +impl Renderer for PpmRenderer { + fn present(&mut self, framebuffer: &[u8], width: u16, height: u16) { + std::fs::create_dir_all(&self.dir).expect("failed to create render dir"); + let path = format!("{}/frame_{:04}.ppm", self.dir, self.frame); + std::fs::write(&path, ppm_bytes(framebuffer, width, height)) + .expect("failed to write PPM file"); + self.frame += 1; + } +} + +pub fn ppm_bytes(framebuffer: &[u8], width: u16, height: u16) -> Vec { + let mut out = format!("P6\n{width} {height}\n255\n").into_bytes(); + for &idx in framebuffer { + let (r, g, b) = index_to_rgb(idx); + out.extend_from_slice(&[r, g, b]); + } + out +} + +// Tests +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ppm_bytes_has_correct_header() { + let fb = [0u8; 4]; + let bytes = ppm_bytes(&fb, 2, 2); + let expected_header = "P6\n2 2\n255\n"; + let got = String::from_utf8_lossy(&bytes[..expected_header.len()]); + assert_eq!(got, expected_header); + } + + #[test] + fn ppm_bytes_emits_rgb_per_pixel() { + let fb = [0x21u8, 0x21, 0x00, 0x3F]; // 4 pixels: 2x2 + let bytes = ppm_bytes(&fb, 2, 2); + let header = format!("P6\n2 2\n255\n"); + let pixels = &bytes[header.len()..]; + assert_eq!(pixels.len(), 2 * 2 * 3); // 12 RGB bytes + + assert_eq!(&pixels[0..3], &[0x64, 0x8D, 0xFF]); // pixel 0 = 0x21, unchanged + assert_eq!(&pixels[9..12], &[0x66, 0x87, 0x96]); // pixel 3 = 0x3F (was 6..9) + } +} \ No newline at end of file