Tutorial 1: Editing a File in a ROM

This tutorial will explain the process of opening a ROM file, extracting a file from it, putting a modified version of the file back into the ROM, and saving the modified ROM.

Note

We’re going to do this the way I would personally do it – by making small temporary scripts that accomplish whatever we need. To make it easier to follow, I’ll post the entire script file every time it changes, instead of just the edited parts. (The modified lines will be highlighted, though.)

Opening the ROM

The first step will be to import the ndspy module dedicated to ROM files, ndspy.rom. Make a new, empty Python file (say, rom_files_tutorial.py), open it, and write the following:

1import ndspy.rom

Try running it at this point. If it finishes without any errors, everything’s good so far.

Of course, we need to have a ROM to use. For ease of access, it’s a good idea to put it in the same folder as your Python script. I’ll be using New Super Mario Bros. (nsmb.nds), but the same technique will work on just about any ROM.

ndspy.rom provides a class that we can use to work with ROM files: NintendoDSRom. Its constructor takes a bytes object containing the ROM file data, so we’ll need to get one of those. This step doesn’t really involve ndspy per se:

1import ndspy.rom
2
3with open('nsmb.nds', 'rb') as f:
4    data = f.read()

'rb' there tells open() to open the file for reading in binary mode. We can print the beginning of the data to see what it looks like:

1import ndspy.rom
2
3with open('nsmb.nds', 'rb') as f:
4    data = f.read()
5
6print(data[:50])
b'NEW MARIO\x00\x00\x00A2DE01\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x08\x00\x02\x00\x00\x00\x02\xa4\xef\x05\x00\x00\xe8'

Now we can hand it off to ndspy to get a NintendoDSRom object we can do interesting things with:

1import ndspy.rom
2
3with open('nsmb.nds', 'rb') as f:
4    data = f.read()
5
6rom = ndspy.rom.NintendoDSRom(data)
7
8print(rom)
<rom "NEW MARIO" (A2DE)>

Cool, we now have a NintendoDSRom for NSMB. (NEW MARIO is the game’s internal name. Internal names can be helpful, but they don’t always necessarily match up with a game’s actual name.)

Warning

I’m using rom here as the variable name for the NintendoDSRom object, since it represents a ROM. Be sure not to confuse that with ndspy.rom, which is the module containing the NintendoDSRom class!

Since opening a file, reading its contents as a bytes object, and making a NintendoDSRom from it is a very common thing to do, ndspy provides a shortcut for this:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5print(rom)
<rom "NEW MARIO" (A2DE)>

As you can see, that does the same thing as what we did on our own. Many ndspy classes have .fromFile(filename) functions like this!

Now that we have a ROM object, what can we do with it? Lots of things! For example, we can see how many files it contains:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5print(len(rom.files))
2088

Or we can check what memory address the main ARM9 code file will be loaded to:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5print(hex(rom.arm9RamAddress))
0x2000000

But of course, what we really want to do is extract a file.

Extracting a file

I’m going to extract polygon_unit/evf_cloud1.nsbtx, which is the texture for the foreground clouds in World 7-1.

../_images/rom-before.png

What World 7-1 looks like in regular New Super Mario Bros. You can see one-and-a-half foreground clouds in this screenshot.

Before continuing, you need to understand the relationship between files, filenames, and file IDs in ROMs. There’s an explanation in the introductory material for the ndspy.fnt module (which is used internally by ndspy.rom) which I recommend you read: Filenames and file IDs. What you really need to know, though, is that files are fundamentally accessed by ID, and IDs are indices into a list of all files in the ROM. Filename tables are separate, exist only for convenience, and simply map file (and folder) names to file IDs.

So we need to get the file ID for polygon_unit/evf_cloud1.nsbtx. A NintendoDSRom’s filenames table is provided as a ndspy.fnt.Folder, in a .filenames attribute. We can print that out to show all of the filenames and their corresponding file IDs (warning: this is a pretty long printout):

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5print(rom.filenames)
0131 00DUMMY
0132 BUILDTIME
0133 mgvs_sound_data.sdat
0134 sound_data.sdat
0135 ARCHIVE/
0135     ARC0.narc
0136     bomthrow.narc
0137     card.narc
  [snip]
1896     pl_ttl_LZ.bin
1897     plnovs_LZ.bin
1898 polygon_unit/
1898     evf_cloud1.nsbtx
1899     evf_haze1.nsbtx
1900     evf_sea1_a.nsbtx
  [snip]
2085     UI_O_menu_title_logo_o_u_ncg.bin
2086     UI_O_menu_title_logo_u.bncl
2087     UI_O_menu_title_o_d_ncg.bin

From this, we can see that the file ID for polygon_unit/evf_cloud1.nsbtx is 1898. How would we get that programmatically, though? Pretty easily, actually:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5print(rom.filenames.idOf('polygon_unit/evf_cloud1.nsbtx'))
1898

ndspy again provides a shortcut for this: ndspy.fnt.Folders support indexing syntax for converting between filenames and file IDs. We can use that here to make the code a bit shorter:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5print(rom.filenames['polygon_unit/evf_cloud1.nsbtx'])
1898

Now we can simply get the data for that file by using that file ID as an index into the ROM’s .files attribute:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5cloudNSBTXFileID = rom.filenames['polygon_unit/evf_cloud1.nsbtx']
6cloudNSBTX = rom.files[cloudNSBTXFileID]
7
8print(cloudNSBTX[:50])
bytearray(b'BTX0\xff\xfe\x01\x00\x84a\x00\x00\x10\x00\x01\x00\x14\x00\x00\x00TEX0pa\x00\x00\x00\x00\x00\x00\x00\x00<\x00\x00\x00\x00\x00\x90\x00\x00\x00\x00\x00\x00\x00\x00\x08')

Cool.

Note

You might be wondering what this “bytearray” is, and why we didn’t get a bytes object. A bytearray is essentially a mutable version of bytes, meaning it can be modified. ndspy provides the data for files within the ROM as bytearrays to make it a bit more convenient to edit them.

Since it’s pretty common to want to get the data for the file with some filename, ndspy yet again has a shortcut for it:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5cloudNSBTX = rom.getFileByName('polygon_unit/evf_cloud1.nsbtx')
6
7print(cloudNSBTX[:50])
bytearray(b'BTX0\xff\xfe\x01\x00\x84a\x00\x00\x10\x00\x01\x00\x14\x00\x00\x00TEX0pa\x00\x00\x00\x00\x00\x00\x00\x00<\x00\x00\x00\x00\x00\x90\x00\x00\x00\x00\x00\x00\x00\x00\x08')

Note

At this point, you’re probably wishing I would just jump straight to the shortcut syntax in the first place. Well, I think it’s important to have some idea of what the shortcuts are shortcuts for, especially since you won’t always be able to use them in every situation. For example, the .fromFile(filename) functions aren’t very useful if you want to load a file that you got from a ROM. ROM files are provided as bytes objects, so you’re better off using class constructors that take those instead.

Anyway, now we can go ahead and save evf_cloud1.nsbtx to an actual file outside of the ROM:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5cloudNSBTX = rom.getFileByName('polygon_unit/evf_cloud1.nsbtx')
6
7with open('evf_cloud1.nsbtx', 'wb') as f:
8    f.write(cloudNSBTX)

Now you can open that file with some other tool (such as MKDS Course Modifier) and make changes as you see fit.

Go ahead and do that now. I’ll wait.

Replacing a file

Done? Time to replace the file in the ROM with our new copy, then!

Let’s suppose you saved the modified NSBTX file to evf_cloud1_edited.nsbtx. Our goal is to get that as a bytes object and put it into rom.files. One step at a time, though – let’s start by getting the new file data:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5with open('evf_cloud1_edited.nsbtx', 'rb') as f:
6    cloudNSBTXEdited = f.read()

This gets us the NSBTX data and puts it in cloudNSBTXEdited. We can put said data into the files list using the file ID:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5with open('evf_cloud1_edited.nsbtx', 'rb') as f:
6    cloudNSBTXEdited = f.read()
7
8rom.files[rom.filenames['polygon_unit/evf_cloud1.nsbtx']] = cloudNSBTXEdited

Or with ndspy’s shortcut function for accomplishing the same thing:

1import ndspy.rom
2
3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
4
5with open('evf_cloud1_edited.nsbtx', 'rb') as f:
6    cloudNSBTXEdited = f.read()
7
8rom.setFileByName('polygon_unit/evf_cloud1.nsbtx', cloudNSBTXEdited)

Done. All that’s left now is to save the modified ROM so we can try it out!

Saving the ROM

NintendoDSRom provides a .save() function that returns a bytes object, which we can use to save the ROM:

 1import ndspy.rom
 2
 3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
 4
 5with open('evf_cloud1_edited.nsbtx', 'rb') as f:
 6    cloudNSBTXEdited = f.read()
 7
 8rom.setFileByName('polygon_unit/evf_cloud1.nsbtx', cloudNSBTXEdited)
 9
10with open('nsmb_edited.nds', 'wb') as f:
11    f.write(rom.save())

Naturally, though, there’s a shortcut for that:

 1import ndspy.rom
 2
 3rom = ndspy.rom.NintendoDSRom.fromFile('nsmb.nds')
 4
 5with open('evf_cloud1_edited.nsbtx', 'rb') as f:
 6    cloudNSBTXEdited = f.read()
 7
 8rom.setFileByName('polygon_unit/evf_cloud1.nsbtx', cloudNSBTXEdited)
 9
10rom.saveToFile('nsmb_edited.nds')

And that’s all there is to it! Go try your ROM out and enjoy whatever change you made.

../_images/rom-after.png

Perfect.