diff options
| author | Miquel Sabaté Solà <mssola@mssola.com> | 2026-10-06 17:06:39 +0200 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2026-10-06 17:06:39 +0200 |
| commit | b2ec385bf83ad895208e7eb481f704cf8a12a6c6 (patch) | |
| tree | 53fbf977ad7b7b0f59fabe0b0966696e16763f52 | |
| parent | 994cd8561e2e08432fa5dcb4a86ad52b206fe360 (diff) | |
| download | tools.nes-b2ec385bf83ad895208e7eb481f704cf8a12a6c6.tar.gz tools.nes-b2ec385bf83ad895208e7eb481f704cf8a12a6c6.zip | |
readrom: add support for MMC3
In theory it's support for any kind of memory mapper, but in practice
only MMC3 has been added.
Signed-off-by: Miquel Sabaté Solà <mssola@mssola.com>
| -rw-r--r-- | crates/readrom/README.md | 65 | ||||
| -rw-r--r-- | crates/readrom/src/main.rs | 138 | ||||
| -rwxr-xr-x | scripts/test-e2e.sh | 9 | ||||
| -rw-r--r-- | tests/expected/readrom/blink-nmi.txt | 30 |
4 files changed, 226 insertions, 16 deletions
diff --git a/crates/readrom/README.md b/crates/readrom/README.md index 318d74b..a1175fe 100644 --- a/crates/readrom/README.md +++ b/crates/readrom/README.md @@ -134,3 +134,68 @@ $ readrom -d nmi -n .nasm/ --raw jetpac.NTSC.nes | hexdump -C 000000b0 02 20 a9 00 8d 05 20 8d 05 20 20 1d 8b a9 7f 25 |. .... .. ....%| 000000c0 20 85 20 68 a8 68 aa 68 40 | . h.h.h@| ``` + +## Support for Memory Mapper Chips + +All of the above is fine but it only applies safely to NROM. That is, whenever +the cartridge fits neatly on the basic 40KB size. But if the cartridge goes +beyond that thanks to a Memory Mapper Chip, then it will happen that the code +references addresses which might be from different banks of memory. + +For now, only [MMC3](https://www.nesdev.org/wiki/MMC3) is supported, and in +order for things to work you have to provide the `--mmc` flag, which accepts a +string with the following format: `kind=mmc3; prg_rom_mode=<0|1>; r6=<int>; +r7=<int>`. That is, you need to specify: + +- How banks are going to be mapped via the `prg_rom_mode` bit. +- The values for the registers `r6` and `r7`. + +With all of this, you can take +[fx/blink.s](https://git.mssola.com/nes/code.nes/tree/fx/blink.s) and build it +like so: + +``` +$ nasm -c config/mmc3.cfg --asan --write-info -o blink.nes fx/blink.s +``` + +And then, if you'd like to disassemble the `nmi` handler, you have to: + +- Provide the path to the `.nasm/` directory as before. +- Set `prg_rom_mode` to 0, as that's the setting for this game. +- Set `r6` to 0 and `r7` to 1, as that's what the game is expecting. + +Then: + +``` +$ readrom -d nmi -n .nasm/ --mmc "kind=mmc3; prg_rom_mode=0; r6=0; r7=1" blink.nes +$E138: 24 20 bit $20 +$E13A: 10 2A bpl @next +$E13C: 48 pha +$E13D: 8A txa +$E13E: 48 pha +$E13F: 98 tya +$E140: 48 pha +$E141: E6 00 inc Vars::zp_counter +$E143: 20 8B C0 jsr Diskun::nmi_update +$E146: A9 00 lda #$00 +$E148: 8D 03 20 sta $2003 +$E14B: A9 02 lda #$02 +$E14D: 8D 14 40 sta $4014 +$E150: 2C 02 20 bit $2002 +$E153: A9 00 lda #$00 +$E155: 8D 05 20 sta $2005 +$E158: 8D 05 20 sta $2005 +$E15B: A9 7F lda #$7F +$E15D: 25 20 and $20 +$E15F: 85 20 sta $20 +$E161: 68 pla +$E162: A8 tay +$E163: 68 pla +$E164: AA tax +$E165: 68 pla + + @next: +$E166: 40 rti + + irq: +``` diff --git a/crates/readrom/src/main.rs b/crates/readrom/src/main.rs index 09b394d..1b800fb 100644 --- a/crates/readrom/src/main.rs +++ b/crates/readrom/src/main.rs @@ -9,6 +9,18 @@ use xixanta::opcodes::OPCODES; /// Version for this program. const VERSION: &str = "0.1.0"; +// Required configuration for the MMC3 chip. +struct Mmc3 { + prg_rom_mode: bool, + r6: usize, + r7: usize, +} + +// Accepted parameters for the '--mmc' flag. +enum MemoryMapperChip { + Mmc3(Mmc3), +} + #[derive(Default)] struct Args { file: String, @@ -18,6 +30,7 @@ struct Args { mapping: Option<String>, nasm: Option<PathBuf>, raw: bool, + mmc: Option<MemoryMapperChip>, config: Option<String>, } @@ -33,12 +46,46 @@ fn print_help() { println!(" -h, --help\t\t\tPrint this message."); println!(" -H, --header\t\t\tJust print the ROM header and quit."); println!(" -m, --mapping <NAME>\t\tDisassemble the mapped segments as referenced by NAME."); + println!(" --mmc <CONFIG>\t\tProvide the configuration for a memory mapper chip."); println!(" -n, --nasm <PATH>\t\tPath to the .nasm/ directory."); println!(" -r, --raw\t\t\tPrint bytes with no formatting at all when disassembling."); println!(" -v, --version\t\t\tPrint the version of this program."); std::process::exit(0); } +// Given an option line, parse it in order to fetch the configuration for a +// memory mapper chip. If something goes wrong, it will simply call die(). +fn parse_mmc(line: &str) -> MemoryMapperChip { + let map: HashMap<String, String> = line + .split(';') + .filter_map(|pair| pair.split_once('=')) + .map(|(k, v)| (k.trim().to_lowercase(), v.trim().to_lowercase())) + .collect(); + + match map.get("kind").map(|s| s.as_str()) { + Some("mmc3") => { + let parse_field = |key: &str| -> usize { + map.get(key) + .unwrap_or_else(|| { + die(format!("missing required field '{key}' in '--mmc' flag")) + }) + .parse::<usize>() + .unwrap_or_else(|_| die(format!("invalid integer for '{key}' in '--mmc' flag"))) + }; + + MemoryMapperChip::Mmc3(Mmc3 { + prg_rom_mode: parse_field("prg_rom_mode") == 1, + r6: parse_field("r6"), + r7: parse_field("r7"), + }) + } + Some(mmc) => die(format!( + "unknown memory mapper kind '{mmc}' in the '--mmc' flag" + )), + None => die("you need to specify the 'kind' in the '--mmc' flag".to_string()), + } +} + // Parse the arguments given to the program and returns an Args object with the // given information. fn parse_arguments() -> Args { @@ -74,6 +121,10 @@ fn parse_arguments() -> Args { die("you need to specify an address for the '-m/--mapping' flag".to_string()) } }, + "--mmc" => match args.next() { + Some(c) => res.mmc = Some(parse_mmc(&c)), + None => die("you need to specify a configuration for the '--mmc' flag".to_string()), + }, "-n" | "--nasm" => match args.next() { Some(a) => { let pb = PathBuf::from(a.clone()); @@ -212,11 +263,9 @@ fn print_range( ) -> Result<(), String> { // Put a lower and upper bounds to the bytes to be used for printing. - // NOTE: minus 0x8000 to account for non-ROM address, plus 0x10 to skip the - // header from the file. + // NOTE: minus 0x8000 to account for non-ROM address. let range_start = start .checked_sub(0x8000) - .map(|val| val + 0x10) .ok_or_else(|| format!("bad start address {:#x}", start))?; // The 'end' of the range depends on whether the user is just printing a @@ -225,7 +274,6 @@ fn print_range( Some(e) => { let range_end = e .checked_sub(0x8000) - .map(|val| val + 0x10) .ok_or_else(|| format!("bad end address {:#x}", e))?; bytes.get(range_start..range_end).unwrap_or(&[]) } @@ -535,6 +583,53 @@ fn handle_disassembling_args(args: &Args, bytes: &[u8]) -> Result<bool, String> Ok(false) } +// Returns the same 'bytes' but reconstructed so it fits into the banking +// configuration established in 'mmc'. +fn reconstruct_from_mmc3(header: &Header, mmc: &Mmc3, bytes: &[u8]) -> Result<Vec<u8>, String> { + // We first grab the bytes for PRG ROM. + let rom_size = header.prg_rom_size * 16 * 1024; + let prg_rom = bytes.get(0x10..(rom_size + 0x10)).unwrap_or(&[]); + if prg_rom.is_empty() { + return Err("malformed ROM file: PRG ROM is empty".to_string()); + } + + // We split the PRG ROM into 8KB chunks, which are the base unit for PRG ROM + // bank switching. If PRG ROM cannot be divided into these chunks, there's + // something off with the ROM file. + let (chunks, remainder) = prg_rom.as_chunks::<{ 8 * 1024 }>(); + if !remainder.is_empty() { + return Err("malformed ROM file: PRG ROM size is not a multiple of 8KB".to_string()); + } + + // Pair each chunk with the proper identifier. + let bank_r6 = *chunks.get(mmc.r6).ok_or("mmc3: invalid R6 bank index")?; + let bank_r7 = *chunks.get(mmc.r7).ok_or("mmc3: invalid R7 bank index")?; + let bank_second_last = *chunks + .get(chunks.len().saturating_sub(2)) + .ok_or("mmc3: PRG ROM too small")?; + let bank_last = *chunks + .get(chunks.len().saturating_sub(1)) + .ok_or("mmc3: PRG ROM too small")?; + + // And then, according to the established PRG ROM mode, let's sort each + // register as expected by the MMC3 chip and push it into the vector we end + // up returning. + let mut ret = Vec::with_capacity(rom_size); + if mmc.prg_rom_mode { + ret.extend_from_slice(&bank_second_last); + ret.extend_from_slice(&bank_r7); + ret.extend_from_slice(&bank_r6); + ret.extend_from_slice(&bank_last); + } else { + ret.extend_from_slice(&bank_r6); + ret.extend_from_slice(&bank_r7); + ret.extend_from_slice(&bank_second_last); + ret.extend_from_slice(&bank_last); + } + + Ok(ret) +} + fn main() { let args = parse_arguments(); @@ -547,6 +642,28 @@ fn main() { die(e.to_string()); } + let buf = bytes.get(0..0x10).unwrap_or(&[]); + if buf.is_empty() { + die("malformed ROM file".to_string()); + } + + let header = match Header::try_from(buf) { + Ok(h) => h, + Err(e) => die(e.to_string()), + }; + + // If the ROM file is using a special mapper (e.g. MMC3), it may provide a + // memory mapper chip configuration. If that's the case, then 'bytes' might + // need to be re-constructed with the given mapper configuration. Otherwise, + // we remove the header part from the ROM, as it's no longer needed. + bytes = match &args.mmc { + Some(MemoryMapperChip::Mmc3(m)) => match reconstruct_from_mmc3(&header, m, &bytes) { + Ok(b) => b, + Err(e) => die(e), + }, + None => bytes.get(0x10..).unwrap_or(&[]).to_vec(), + }; + // Check whether the user wanted to disassemble something. match handle_disassembling_args(&args, &bytes) { Ok(quit) => { @@ -559,24 +676,13 @@ fn main() { // Nope. Then let's just print information about it. First the header. - let buf = bytes.get(0..0x10).unwrap_or(&[]); - if buf.is_empty() { - die("malformed ROM file".to_string()); - } - - let header = match Header::try_from(buf) { - Ok(h) => h, - Err(e) => die(e.to_string()), - }; print_header(&header); - if args.header { std::process::exit(0); } // Vectors. - let rom_size = header.prg_rom_size * 16 * 1024; - let vectors = &bytes.get(0x10 + rom_size - 6..).unwrap_or(&[]); + let vectors = &bytes.get(0x7FFA..).unwrap_or(&[]); print_vectors(vectors); } diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index 4b9af90..21c4c7f 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -274,6 +274,15 @@ echo "test: readrom: full NMI disassembling (jetpac.nes)" diff tests/out/jetpac-full-nmi.txt tests/expected/readrom/jetpac-full-nmi.txt exit_code=$((exit_code + $?)) +echo "test: readrom: full NMI disassembling on MMC3 project (code.nes)" +./target/debug/nasm -c tests/code.nes/config/mmc3.cfg --asan --write-info -o tests/out/blink.nes tests/code.nes/fx/blink.s +./target/debug/readrom -d nmi -n tests/code.nes/.nasm/ --mmc "kind=mmc3; prg_rom_mode=0; r6=0; r7=1" tests/out/blink.nes > tests/out/blink-nmi.txt +diff tests/out/blink-nmi.txt tests/expected/readrom/blink-nmi.txt +exit_code=$((exit_code + $?)) + +# Remove the .nasm/ directory that has been created for code.nes. +rm -r tests/code.nes/.nasm + # Remove the dangling jetpac.nes ROM file. rm tests/out/jetpac.NTSC.nes diff --git a/tests/expected/readrom/blink-nmi.txt b/tests/expected/readrom/blink-nmi.txt new file mode 100644 index 0000000..25e983a --- /dev/null +++ b/tests/expected/readrom/blink-nmi.txt @@ -0,0 +1,30 @@ +$E138: 24 20 bit $20 +$E13A: 10 2A bpl @next +$E13C: 48 pha +$E13D: 8A txa +$E13E: 48 pha +$E13F: 98 tya +$E140: 48 pha +$E141: E6 00 inc Vars::zp_counter +$E143: 20 8B C0 jsr Diskun::nmi_update +$E146: A9 00 lda #$00 +$E148: 8D 03 20 sta $2003 +$E14B: A9 02 lda #$02 +$E14D: 8D 14 40 sta $4014 +$E150: 2C 02 20 bit $2002 +$E153: A9 00 lda #$00 +$E155: 8D 05 20 sta $2005 +$E158: 8D 05 20 sta $2005 +$E15B: A9 7F lda #$7F +$E15D: 25 20 and $20 +$E15F: 85 20 sta $20 +$E161: 68 pla +$E162: A8 tay +$E163: 68 pla +$E164: AA tax +$E165: 68 pla + + @next: +$E166: 40 rti + + irq: |
