aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mssola@mssola.com>2026-10-06 17:06:39 +0200
committerMiquel Sabaté Solà <mssola@mssola.com>2026-10-06 17:06:39 +0200
commitb2ec385bf83ad895208e7eb481f704cf8a12a6c6 (patch)
tree53fbf977ad7b7b0f59fabe0b0966696e16763f52
parent994cd8561e2e08432fa5dcb4a86ad52b206fe360 (diff)
downloadtools.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.md65
-rw-r--r--crates/readrom/src/main.rs138
-rwxr-xr-xscripts/test-e2e.sh9
-rw-r--r--tests/expected/readrom/blink-nmi.txt30
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: