aboutsummaryrefslogtreecommitdiff
path: root/crates/readrom
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mssola@mssola.com>2026-07-15 22:11:50 +0200
committerMiquel Sabaté Solà <mssola@mssola.com>2026-07-15 22:11:50 +0200
commit9d790bb4acddae689c5dda667f9d160041bdd4b8 (patch)
tree2d719d596a21e63bfe465e2c72f9caababec70c7 /crates/readrom
parent61b9ce33d54bc643b0ece227101d8bf571fd86e5 (diff)
downloadtools.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.toml1
-rw-r--r--crates/readrom/README.md136
-rw-r--r--crates/readrom/src/main.rs275
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(&current_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(&current_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) {