diff options
| author | Miquel Sabaté Solà <mssola@mssola.com> | 2026-07-15 22:11:50 +0200 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2026-07-15 22:11:50 +0200 |
| commit | 9d790bb4acddae689c5dda667f9d160041bdd4b8 (patch) | |
| tree | 2d719d596a21e63bfe465e2c72f9caababec70c7 /crates/readrom | |
| parent | 61b9ce33d54bc643b0ece227101d8bf571fd86e5 (diff) | |
| download | tools.nes-9d790bb4acddae689c5dda667f9d160041bdd4b8.tar.gz tools.nes-9d790bb4acddae689c5dda667f9d160041bdd4b8.zip | |
readrom: implement the -d/--disassemble option
This option can also be coupled with -n/--nasm-directory, and you can
then get a human-readable disassembling of any proc or label you might
be thinking on.
Signed-off-by: Miquel Sabaté Solà <mssola@mssola.com>
Diffstat (limited to 'crates/readrom')
| -rw-r--r-- | crates/readrom/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/readrom/README.md | 136 | ||||
| -rw-r--r-- | crates/readrom/src/main.rs | 275 |
3 files changed, 406 insertions, 6 deletions
diff --git a/crates/readrom/Cargo.toml b/crates/readrom/Cargo.toml index ff2f04b..90f02ea 100644 --- a/crates/readrom/Cargo.toml +++ b/crates/readrom/Cargo.toml @@ -7,3 +7,4 @@ authors.workspace = true [dependencies] header.workspace = true +xixanta.workspace = true diff --git a/crates/readrom/README.md b/crates/readrom/README.md new file mode 100644 index 0000000..5252cb7 --- /dev/null +++ b/crates/readrom/README.md @@ -0,0 +1,136 @@ +`readrom` is an objdump-like utility that reads a given NES/Famicom ROM file and +shows information about it. You can run it by simply passing a ROM file to +it. For example, for the [Jetpac NTSC ROM +file](https://github.com/mssola/jetpac.nes/releases/tag/v1.0): + +``` +$ readrom jetpac.NTSC.nes +# => output +Header: + Kind: NES 2.0 + PRG ROM size: 32768 bytes (32KB) + CHR ROM size: 8192 bytes (8KB) + Mapper: NROM + Mirroring: Horizontal + CPU/PPU timing: NTSC +Vectors: + NMI: 0xa2f8 + Reset: 0xa3c2 + IRQ: 0xa3c1 +``` + +But this utility can do some more complex things. For example, you can tell it +to disassemble a subroutine via the `-d/--disassemble` flag. If the above output +is telling us that NMI code starts at 0xa2f8, we can try: + +``` +$ readrom -d '$a2f8' jetpac.NTSC.nes +# => output +$A2F8: 24 20 bit $20 +``` + +Note that hexadecimal values can also be formatted like this `0xa2fb` or simply +`a2fb`, whatever feels more convenient to use. In any case, they should be +written as a 16-bit address. + +All of that being said, poking for addresses can be tedious. That's why you can +also pass names that you already know. For that, you will need to also pass the +`-n/--nasm-directory`, pointing to the path to your `.nasm/` directory. With +that, if you have assembled the ROM file with `nasm` with the `--write-info` +flag, you will have something like this: + +``` +# On the jetpac.nes repository +$ nasm --write-info -o jetpac.NTSC.nes src/jetpac.s +$ readrom -d nmi -n .nasm/ jetpac.NTSC.nes +# => output +$A2F8: 24 20 bit $20 +$A2FA: 30 01 bmi @save_registers +$A2FC: 40 rti + + @save_registers: +$A2FD: 48 pha +$A2FE: 8A txa +$A2FF: 48 pha +$A300: 98 tya +$A301: 48 pha +$A302: A9 00 lda #$00 +$A304: 8D 03 20 sta $2003 +$A307: A9 02 lda #$02 +$A309: 8D 14 40 sta $4014 +$A30C: 24 28 bit $28 +$A30E: 10 03 bpl @check_pause +$A310: 20 EE 8C jsr nmi_update_scores + + @check_pause: +$A313: 24 38 bit $38 +$A315: 50 03 bvc @increase_rand +$A317: 20 62 A2 jsr nmi_hud_toggle_pause + +# and much more... +``` + +Moreover, if you have built your ROM file with the `--asan` flag from `nasm`, +then you will have the `memory.txt` file inside of `.nasm/`. This tool is able +to pick up this file and decorate some of the values from the previous +output. Hence: + +``` +# On the jetpac.nes repository +$ nasm --write-info --asan -o jetpac.NTSC.nes src/jetpac.s +$ readrom -d nmi -n .nasm/ jetpac.NTSC.nes +# => output +$A2F8: 24 20 bit Globals::zp_flags +$A2FA: 30 01 bmi @save_registers +$A2FC: 40 rti + + @save_registers: +$A2FD: 48 pha +$A2FE: 8A txa +$A2FF: 48 pha +$A300: 98 tya +$A301: 48 pha +$A302: A9 00 lda #$00 +$A304: 8D 03 20 sta OAM::m_address +$A307: A9 02 lda #$02 +$A309: 8D 14 40 sta OAM::m_dma +$A30C: 24 28 bit Globals::zp_extra_flags +$A30E: 10 03 bpl @check_pause +$A310: 20 EE 8C jsr nmi_update_scores + + @check_pause: +$A313: 24 38 bit Driver::zp_flags +$A315: 50 03 bvc @increase_rand +$A317: 20 62 A2 jsr nmi_hud_toggle_pause + +# and much more... +``` + +Now you can see the same output as before, but the first instruction reads as +`bit Globals::zp_flags` instead of `bit $20`, because `readrom` now knows that +the memory address `$20` is associated with this variable. + +Moreover, and as you can tell, by default `readrom` will print things in a +human-readable format. But you can also tell it to just write all the bytes with +the `--raw` flag so you can further manipulate the related bytes with another +tool. For example: + +``` +# On the jetpac.nes repository +$ nasm --write-info -o jetpac.NTSC.nes src/jetpac.s +$ readrom -d nmi -n .nasm/ --raw jetpac.NTSC.nes | hexdump -C +# => output +00000000 24 20 30 01 40 48 8a 48 98 48 a9 00 8d 03 20 a9 |$ 0.@H.H.H.... .| +00000010 02 8d 14 40 24 28 10 03 20 ee 8c 24 38 50 03 20 |...@$(.. ..$8P. | +00000020 62 a2 e6 0a a9 08 25 20 d0 6e 24 2e 10 0b a9 00 |b.....% .n$.....| +00000030 50 02 a9 70 85 04 20 bc a2 a5 50 29 08 f0 34 2c |P..p.. ...P)..4,| +00000040 02 20 a9 28 8d 06 20 a9 4b 8d 06 20 a5 53 18 69 |. .(.. .K.. .S.i| +00000050 10 8d 07 20 24 27 10 15 2c 02 20 a9 28 8d 06 20 |... $'..,. .(.. | +00000060 a9 56 8d 06 20 a5 54 18 69 10 8d 07 20 a5 50 29 |.V.. .T.i... .P)| +00000070 f7 85 50 a5 20 aa 29 20 f0 13 a5 cb d0 06 20 78 |..P. .) ...... x| +00000080 89 4c 7f a3 20 59 91 a5 20 29 df 85 20 8a 29 01 |.L.. Y.. ).. .).| +00000090 d0 06 a5 30 f0 02 c6 30 24 20 50 13 a9 bf 25 20 |...0...0$ P...% | +000000a0 85 20 2c 02 20 a5 81 8d 01 20 a5 80 8d 00 20 2c |. ,. .... .... ,| +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@| +``` diff --git a/crates/readrom/src/main.rs b/crates/readrom/src/main.rs index 3c87417..e2f4343 100644 --- a/crates/readrom/src/main.rs +++ b/crates/readrom/src/main.rs @@ -1,6 +1,9 @@ use header::{Header, Kind}; +use std::collections::HashMap; use std::fs::File; -use std::io::{ErrorKind, Read}; +use std::io::{BufRead, BufReader, BufWriter, ErrorKind, Read, Write}; +use std::path::PathBuf; +use xixanta::opcodes::OPCODES; /// Version for this program. const VERSION: &str = "0.1.0"; @@ -9,15 +12,21 @@ const VERSION: &str = "0.1.0"; struct Args { file: String, header: bool, + disassemble: Option<String>, + nasm: Option<String>, + raw: bool, } fn print_help() { println!("Display information about NES/Famicom ROM files.\n"); println!("usage: readrom [OPTIONS] <FILE>\n"); println!("Options:"); - println!(" -h, --help\t\tPrint this message."); - println!(" -H, --header\t\tJust print the ROM header and quit."); - println!(" -v, --version\t\tPrint the version of this program."); + println!(" -d, --disassemble <ADDRESS>\tDisassemble starting from the given ADDRESS."); + println!(" -h, --help\t\t\tPrint this message."); + println!(" -H, --header\t\t\tJust print the ROM header and quit."); + println!(" -n, --nasm-directory <PATH>\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); } @@ -30,8 +39,14 @@ fn parse_arguments() -> Args { // Skip command name. args.next(); - for arg in args { + while let Some(arg) = args.next() { match arg.as_str() { + "-d" | "--disassemble" => match args.next() { + Some(a) => res.disassemble = Some(a), + None => die( + "you need to specify an address for the '-d/--disassemble' flag".to_string(), + ), + }, "-h" | "--help" => print_help(), "-H" | "--header" => { if res.header { @@ -39,6 +54,11 @@ fn parse_arguments() -> Args { } res.header = true; } + "-n" | "--nasm" => match args.next() { + Some(a) => res.nasm = Some(a), + None => die("you need to specify a file for the '-n/--nasm' flag".to_string()), + }, + "-r" | "--raw" => res.raw = true, "-v" | "--version" => { println!("readrom {VERSION}"); std::process::exit(0); @@ -140,6 +160,240 @@ fn die(message: String) { std::process::exit(1); } +// Print a block of code from the given open ROM 'file'. The range is indicated +// by 'start' and an optional 'end'. If 'end' is None, then the block of code +// will only span a single instruction. Moreover, the 'memories' and the +// 'addresses' maps can help assist the printing with the information taken from +// the .nasm/memory.txt and .nasm/addresses.txt files respectively. Finally, set +// 'raw' to true if you want all bytes to be printed directly into the stdout, +// otherwise a human-readable format will be used. +fn print_range( + mut file: File, + start: usize, + end: Option<usize>, + memories: HashMap<usize, String>, + addresses: HashMap<usize, String>, + raw: bool, + filter: Option<&str>, +) -> Result<(), String> { + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).map_err(|e| e.to_string())?; + + // Fetch the bytes to be printed. + + // NOTE: minus 0x8000 to account for non-ROM address, plus 0x10 to skip the + // header from the file. + 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 + // single instruction or it's really trying to print a proper range. + let res = match end { + 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(&[]) + } + // If it's just one instruction, take the opcode byte and some more to + // account for the maximum size of an instruction on this platform. + None => bytes.get(range_start..range_start + 3).unwrap_or(&[]), + }; + + // Printing raw: blindessly spit bytes to stdout. + if raw { + let mut writer = BufWriter::new(std::io::stdout()); + writer + .write_all(&res[..res.len()]) + .map_err(|_| "cannot write to the stdout".to_string())?; + return Ok(()); + } + + // Print into a more human-readable shape. + + let mut iter = res.iter(); + let mut current_address = start; + while let Some(byte) = iter.next() { + // Given the opcode, fetch the instruction object for it, and how much + // the address should be advanced after printing the instruction. + let (instr, size, formatted) = match OPCODES.get(byte) { + Some(tpl) => { + let mut ins = tpl.clone(); + let mut formatted = format!("{:02X}\t", byte); + + match ins.size { + 2 => { + ins.bytes[0] = *iter.next().unwrap_or(&0); + formatted = format!("{:02X} {:02X}\t", byte, ins.bytes[0]); + } + 3 => { + ins.bytes[0] = *iter.next().unwrap_or(&0); + ins.bytes[1] = *iter.next().unwrap_or(&0); + formatted = + format!("{:02X} {:02X} {:02X}", byte, ins.bytes[0], ins.bytes[1]); + } + _ => {} + } + + ( + ins.to_human(current_address, filter, &memories, &addresses), + ins.size, + formatted, + ) + } + None => (byte.to_string(), 1, byte.to_string()), + }; + + // Do we actually know of a label which points to the current address? + // If so, show it now. + if let Some(address_name) = addresses.get(¤t_address) + && current_address != start + { + println!( + "\n {}:", + address_name.split("::").last().unwrap_or(address_name) + ); + } + + // And print our awesome line :) + println!("${:4X}:\t{}\t{} ", current_address, formatted, instr); + current_address += size as usize; + + if end.is_none() { + break; + } + } + + // Sometimes there is a label marking the end of the code, which is set + // after the last instruction. Show these labels too as some branch + // instructions can use it. + if let Some(address_name) = addresses.get(¤t_address) { + println!( + "\n {}:", + address_name.split("::").last().unwrap_or(address_name) + ); + } + + Ok(()) +} + +fn parse_hex_value(address: &str) -> Option<usize> { + match address.len() { + // Simple 'a2fb' format. + 4 => { + if let Ok(val) = usize::from_str_radix(address, 16) { + return Some(val); + } + } + // nasm's '$a2fb' format. + 5 => { + if let Some(addr) = address.get(1..) + && let Ok(val) = usize::from_str_radix(addr, 16) + { + return Some(val); + } + } + // Standard '0xa2fb' format. + 6 => { + if let Some(addr) = address.get(2..) + && let Ok(val) = usize::from_str_radix(addr, 16) + { + return Some(val); + } + } + _ => {} + } + + None +} + +fn do_disassemble( + input: File, + address: &str, + nasm_path: &Option<String>, + raw: bool, +) -> Result<(), String> { + let mut start = None; + let mut end = None; + let mut is_nasm_path = true; + let mut addresses: HashMap<usize, String> = HashMap::default(); + let mut memories: HashMap<usize, String> = HashMap::default(); + + // Fill up the 'addresses' and the 'memories' maps. + if let Some(path) = nasm_path { + if let Ok(file) = File::open(PathBuf::from(path).join("addresses.txt")) { + let reader = BufReader::new(file); + for line in reader.lines() { + let line = line.map_err(|e| e.to_string())?; + let columns: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if columns.len() != 3 { + return Err("badly formatted address file".to_string()); + } + + let parsed_start = usize::from_str_radix(columns[1], 16) + .map_err(|_| format!("invalid hex value: '{}'", columns[1]))?; + addresses.insert(parsed_start, columns[0].to_string()); + + if columns[0] == address { + start = Some(parsed_start); + end = Some( + usize::from_str_radix(columns[2], 16) + .map_err(|_| format!("invalid hex value: '{}'", columns[2]))?, + ); + } + } + } + + // If the memory.txt file is available, fill up the 'memories' hash. + if let Ok(file) = File::open(PathBuf::from(path).join("memory.txt")) { + let reader = BufReader::new(file); + for line in reader.lines() { + let line = line.map_err(|e| e.to_string())?; + if line.is_empty() || line.starts_with("---") { + break; + } + + let (left, right) = line.split_once(':').unwrap(); + let start = match left.trim().split_once('-') { + Some((start, _)) => usize::from_str_radix(start.get(1..).unwrap(), 16).unwrap(), + None => usize::from_str_radix(left.get(1..).unwrap(), 16).unwrap(), + }; + memories.insert(start, right.trim().to_string()); + } + } + } else { + is_nasm_path = false; + } + + // If this is just a numeric value, take it as is. + if let Some(start) = parse_hex_value(address) { + return print_range(input, start, None, memories, addresses, raw, None); + } + + // Otherwise, print the full range if possible. + if !is_nasm_path { + Err("you need to use the '-n/--nasm-directory' on disassembly".to_string()) + } else if addresses.is_empty() { + Err("failed to open the .nasm/addresses.txt file".to_string()) + } else { + match start { + Some(s) => print_range( + input, + s, + Some(end.unwrap()), + memories, + addresses, + raw, + Some(format!("{address}::").as_str()), + ), + None => Err(format!("could not find address '{address}'")), + } + } +} + fn main() { let args = parse_arguments(); @@ -148,7 +402,16 @@ fn main() { return; }; - // Header. + // Check whether the user wanted to disassemble something. + + if let Some(address) = args.disassemble { + if let Err(e) = do_disassemble(input, &address, &args.nasm, args.raw) { + die(e); + } + std::process::exit(0); + } + + // Nope. Then let's just print information about it. First the header. let mut buf = vec![0u8; 0x10]; if let Err(e) = input.read_exact(&mut buf) { |
