First: If you want to follow along, the repository for this project is on my forgejo server.
On or about September 10th of 2026, I embarked on a quest to build a working rom for the Nintendo DS handheld. Furthermore, I felt like writing in rust. When I embarked on this quest I was spurred on by having recently found and skimmed a blogpost on the same subject, and done some minor investigation of the DS homebrew scene as an outgrowth of my work on game boy software. So I felt confident this would be easy - follow the instructions in the blogpost, produce output, bada bing bada boom! I'd be able to happily write my rust code and see it run on my actual handheld one day.
Unfortunately, the blogpost in question was not as thorough as I had thought, and I was quickly taken on a whirlwind tour of executables, linking, and makefiles. It was kinda rough! But I stuck with it and persevered. This is my story.
The first thing I did was set up rust and the blocksds toolchain. Rust was fairly easy - install rustup and rust with my system package manager, and then follow the instructions on the blocksds wiki to set that up. It involves installing an experimental toolchain called Wonderful so you can use wf-pacman, but hey, you can't compile for the nintendo ds without installing a few random hobby toolchains, eh?
The next step was to start following the blogpost, and this is where it gets a little rocky. See, the blogpost in question is a little... Light on the details, I suppose. And. Well. I don't want to get into discourse about AI so I'm going to be charitable and not get into my complex feelings on it. But this blogpost had some handwaving "and then I had claude make a makefile for me" right where I needed the most detail, and what's more, they didn't link a repo for their final project. The only code samples they provided in full were of their bindgen crate (vibe coded). So it was really hard for me to learn from them! And that was frustrating. Okay, that's rant over. With the rant over I want to also say explicitly that I'm really grateful that they wrote this post, because I wouldn't have started or finished this project without it.
Just to get out ahead of it, the order of operations looks something like this:
- Set up toolchains (BlocksDS and rust)
- Set up bindgen to be able to reference BlocksDS libraries from rust space
- Write some rust code!
- Compile that rust code
- Mix it together with the bindings generated by bindgen, and the BlocksDS libraries, to make an executable file for the NDS's processor
- Use
ndstoolto combine that executable with everything else the rom needs and compile it into a final.ndsfile!
So, the basic situation is this: BlocksDS provides a bunch of libraries which you're intended to use and link into your C/C++ app. We're going to use bindgen to generate a foreign function interface so we can call the C/C++ functions and constants from within the rust space. We've already got all this stuff installed, so there's not much to do but start! Sadly, I don't actually program C or C++ regularly, and I'm a little out of my depth - but better learning to swim late than never!
The normal way this works, aiui, is that you have a little include "nds.h" invocation at the top of your C++ file, and then your compiler will find that by some kind of oogly-boogly magic, use it to verify you're referencing variables and functions that are supposed to exist in this "static library" libnds or whatnot, and also it'll find the file nds.so... somewhere? and do some magic "linking" step that combines it with the compiled binary from your code and turn it all into one giant blob of executable instructions and metadata in an ELF file which has the magic property that you can double click it or invoke it in the terminal and have this run. The hitch is that most of this stuff "just works" under the hood - my compiler is checking in system folders or something, where the libnds.h and libnds.so files were helpfully installed by my package manager, and everything plays nice. However! In this case! Everything's all wucky! Because we're cross-compiling (compiling for a different system and indeed a different processor architecture) and also we're using libraries which weren't installed in the "normal" way. So it's not going to "just work". In particular, we're going to have to pay a lot more attention to the compiling and linking steps.
So, I thought this was going to be a simple process where I chuck some cargo commands in a Makefile and then run ndstool at the end and it all works. but it wasn't.
The first thing I had to learn about after installing stuff was bindgen, so we'll start there.
Bindgen
First things first, add bindgen = "0.73.2" to the [build-dependencies] section of our Cargo.toml file to tell rust that this needs bindgen installed to build.
Bindgen is a rust library that generates rust bindings for C/C++ code. It gets fed a .h header file (which specifies the shape and signature of constants and functions from a library) and generates a set of rust-compatible wrapper functions that we can include and call from our code (when we, someday, write some). The first thing I needed to do was create a wrapper file that includes all the .h files I want to reference. Maybe I could do this differently, but it's my turn on the steering wheel. Anyway, I make wrapper.h which literally only has the line #include "nds.h" and nothing else.
Then I have to make a build.rs file. This will get detected by cargo (the rust build tool) and run as a build script before compiling my rust code. It's got a bunch of stuff configuring and invoking the bindgen library to point it at the wrapper.h file. The blocks are simple enough.
First we declare a bunch of variables for the paths to wonderful and blocksds, grabbing them from environment variables if possible.
Second, we emit some settings for cargo - println!() in a build.rs file will emit things to be run by cargo, rather than just printing to stdout. Here we're saying "if wrapper.h changes, rerun this build script" and also "look in the libnds folder for libraries". I don't really understand these lines (println!("cargo:include={}", libnds);, println!("cargo:rustc-link-search={}", libnds);, and println!("cargo:rustc-link-lib=nds");) but they seem clear enough and my code compiles so it can't be TOO wrong.
Third, we build the actual bindings object. bindgen uses clang under the hood to parse and analyze .h files, I think? And so we need to tell clang how to operate. We point it at wrapper.h, then .use_core() seems to be important for some reason, then we have a host of clang configs that set up the processor we're building for (-DARM9 and -target arm-none-eabi and -mcpu=arm946e-s), then the -isystem is I think specifying the system libraries to use? Which need to be the ones packaged by blocksds for the arm-none-eabi triple we're working with. Finally, I don't know why I have .layout_tests or .generate_comments or .derive_default but at least one of them is necessary. Finally, the .generate() call generates the bindings!
Fourth, we actually write those bindings that got generated to a file in the output directory. This way it can be included by our code when it actually gets compiled.
Fifth, well, I found that at the actual compilation step I got a bajillion errors thrown from the bindings file until I included the next bunch of steps, which apparently compile C wrappers for static inline functions and macros. I think these are basic things that would be in standard libraries or C core stuff but is missing because we're working in such a weird environment? Either way, it seems to execute the blocksds-included copy of gcc with a bunch of arguments specifying both the weird architecture/processor we're compiling for, and the location of includes packaged with blocksds. I'll be honest: I copied this from the vibe-coded buildgen and it seems to just work so I didn't touch it much.
If that all worked, we should be done with the bindgen step, and when we do cargo build, it'll happily generate a bindings.rs file that we can include via a snappy include!(concat!(env!("OUT_DIR"), "/bindings.rs")); line at the top of our eventual rust code. Oh, and because that includes the bindings.rs directly into the file, we'll also need some instructions to the compiler not to worry about some wonky stuff - thus all the #![ directives at the top. They make sure we don't get warnings for weird, un-rust-y code, and ensure we're not using the standard library I think, and uh. That's that!
Rust
Now we get to some rust! Unfortunately most of this is left as an exercise to the reader. So far I'm just calling an init function and printing something to the screen using blocksds functionality to verify that it's doing what I want.
The important things are that we have a main function with a signature like pub extern "C" fn main() -> i32 to tell rust "hey, expose this for use by other libraries!" (I think).
The other most important thing is that we want to be compiling a static library, because we're going to manually link it later. In order to get rust to compile it as a static library you need to include a [lib] section in Cargo.toml which should say crate-type = ["staticlib"]. If we don't do this, rust won't create a .a file, it'll create a... .d file? Or something? That's what happened to me.
Finally, we need a cargo invocation: cargo +nightly build --target armv5te-none-eabi -Zbuild-std=core. This importantly says to compile for the target triple we want, and also sets the build-std=core flag that I... don't fully understand. I think it's to do with what standard library we're using, and we want a minimal one so we're not throwing in the kitchen sink? But that flag requires we use the nightly channel, so it may change by the time you follow this guide. Who knows?
Make
One of the other curiosities I had going into this was, hey, maybe I'll use this project to finally learn more about Makefiles. And so I did! It was pretty easy. I recommend looking up a tutorial on make, there's a lot of good ones. Probably I could have done some or all of this in build.rs, but I don't know it well enough to do that. And make's not that scary.
Basically, make usually operates on a dependency tree of "rules", which are conventionally like, output files. So if a file out.a depends on some file library.so, you can write a rule for how to produce out.a starting with out.a: library.so. Then make will intelligently figure out when to run the command you specify based on when files were last modified, or something. Anyway, the important thing is to include that cargo invocation from above, and it will automatically run all the bindgen stuff when it runs build.rs. Hooray!
Next up is the beast of the project: linking. See, what we've got so far makes a "static library", which needs to be "linked" with all the stuff that it depends on in some obtuse step that I don't really understand.
This was a lot of trial and error. See, it's made worse by the fact that the linking step in the blocksds example makefiles is badly obscured by the conventions of complex makefiles that depend on a jillion different variables and indirection, and the linking step in the blogpost I followed is just one line with the rest of the Makefile missing. But here's what I've figured out. First off, there's a linker called ld which used to be its own thing, but now it's kind of just included in gcc or something? So I would expect the linking step to be an invocation of ld but it's not.
Jumping to the chase, the linking step (which, remember, turns our static library into an executable binary) is as follows:
$(WONDERFUL_TOOLCHAIN)/toolchain/gcc-arm-none-eabi/bin/arm-none-eabi-gcc -mthumb -mcpu=arm946e-s+nofp \
-L$(BLOCKSDS)/libs/libnds/lib \
-Wl,--start-group \
target/armv5te-none-eabi/debug/librust_test.a \
$(shell find target/armv5te-none-eabi/ -name nds_wrapper.o) \
$(BLOCKSDS)/libs/libnds/lib/libnds9.a \
-lnds9 \
-lc -Wl,--end-group \
-specs=$(BLOCKSDS)/sys/crts/ds_arm9.specs \
-o $@
Wuff. Okay, we can do this. First of all, $() brackets are replaced with the output of their contents executed as shell commands. So $(WONDERFUL_TOOLCHAIN)/toolchain/blah just means to replace it with the contents of the environment variable WONDERFUL_TOOLCHAIN which is the location of wonderful (the toolchain used to install blocksds).
- The first line is the invocation of the included
arm-none-eabi-gccbinary, which as I said is standing in for our linker. Then it gets further told to generate "thumb" code (I think. This is something weird and specific to these old ARM CPUs.) and also to target the specific cpu we're looking for. I don't know what else this does. - The second line specifies (
-L) a library location to search in, so that when the object file (the output of cargo) asks for a library, it can find it from the blocksds libraries. - The third line starts a "group", maybe to specify what files we want to link? Maybe that's what -Wl does, to say we're linking them?
- Then we specify the static library we want to link, which is the output of our delicious rust code's compilation.
- Next we look for the compiled `wrapper.h` object, which we named `nds_wrapper.o`. Need that too.
- Then specify that we want
libnds9.a, which is the libnds object as compiled for the ARM9 core of the ds. This was included with BlocksDS. -lnds9also specifies that it should look for the library which will (by convention) be namedlibnds9. This might be the same thing the previous line accomplishes.- I have no idea what
-lcreally does, but I assume it specifies that we want to link againstlibcas well? I don't... know what that is. Finally we end the group. - The specs flag is basically a bunch of extra flag specifications that we get from BlocksDS so that it compiles right. I don't understand this file but I don't think it has anything I would need to change.
- Finally,
-o $@says that the output file should be$@which is expanded to "the name of the current rule in the makefile". So since I called this rule game_executable, it's outputting a file namedgame_executable.
Final steps
We're so close! We've now created an executable file with all the code we need in order to run our precious rust code. However, it's still in a generic ELF executable format, so it needs to be turned into an NDS rom proper. An NDS rom has at least an ARM9 binary (for the main CPU) and an ARM7 binary (for the secondary). The tool we use to combine all the stuff together is ndstool, again helpfully provided as part of BlocksDS.
We just need a rule in the makefile that will generate the rom. It looks like this:
$(BLOCKSDS)/tools/ndstool/ndstool -9 $@ -7 $(BLOCKSDS)/sys/default_arm7/arm7.elf -c $@.nds
By this point, this should be old hat: look in the directory specified by the BLOCKSDS environment variable for ndstool, and execute it with arguments:
-9 $@says that we should use the file matching the name of this rule for the ARM9 core's image.-7 $(BLOCKSDS)/sys/default_arm7/arm7.elfsays to use the BlocksDS included ARM7 image.- And
-c $@.ndsspecifies the output filename.
Since our rule was named game_executable, this should give us a working rom: game_executable.nds!
Hooray!
This was a lot to learn. I feel like I've really leveled up my understanding of build processes and makefiles and everything. And I'm excited to actually write an NDS rom, not just enable myself to build it!