The remainder of the series is going to be a series of technical deep dives that are not necessarily in chronological order of discovery. I don't claim that everything is factually correct, just my understanding of it.
The outer layer
Like any other program, NDS games are packed as code binaries and asset files. NitroPacker is a community tool that you can point at any game, and it'll unpack the top-level files. It has some niceties like allowing determinism when rebuilding the game.
It supports most operating systems, so it made it a great fit for my Windows + WSL (Linux) setup.
Binaries - BIN
In the case of this game the code lives in a mix of arm9.bin, arm7.bin and three overlay files: main_0000.bin, main_0001.bin and main_0002.bin. They're native ARM machine code, sometimes flipped into THUMB mode, a second 16-bit instruction encoding the same CPU understands, switched on and off as the code runs.
arm9.bin is the main CPU and the closest thing the game has to a kernel. It does the boot and hardware init, owns the heap allocator and the memcpy/memset routines, runs the whole NFP archive system, and hosts the per-frame state machine that dispatches the game into one of 34 modes. It sets everything up and then, every frame, hands control to whichever overlay is currently loaded.
arm7.bin is the DS's second, much smaller processor. It handles the low-level housekeeping every DS game needs: audio mixing, sampling the touchscreen and buttons, wifi, the real-time clock, the microphone, power management. It's almost entirely stock middleware, with nothing translation-relevant inside, so we barely touch it.
main_0001.bin is the actual game. This is where almost all of the interesting, translation-relevant logic lives. We'll dissect it through the blog series.
The other two overlays are not touched or mapped by this project, as nothing in them was relevant to the translation.
Evolving with the agents
My initial approach was to use IDA Pro to reverse-engineer the code back to something readable. I was able to extract a version of the binary that could be explored by agents, but was far from correct and complete.
At the beginning of the project every exploration into the binaries using Opus 4.5 would expend a whole context window. It was near futile, as the agent would go down rabbit holes when finding code that called into the firmware, and would fail to identify code offsets.
With the advent of 1M context windows this part of the project unblocked. I left a horde of agents working overnight on mapping and documenting every function found in the binary. Every parameter, every struct, every memory offset called and written to. This paid dividends later in the project, as we started chasing bugs in the original code.
Teaching the agent to play
Chasing bugs in a running videogame is a pain with the source code in hand. Finding them when you only have access to incomplete binaries is downright impossible.
I have spent no less than three afternoons with the agent setting breakpoints and memory breaks in the running game. I was able to dump the function parameters and RAM at the offsets given by the agent based off the mapped binaries, but analyzing tens of megabytes is still painful even for modern agents.
A revelation came to me months into the project, one day at work. I use agent-browser, a tool that allows agents to run a stateful browser session using playwright. What if I could make the agents be able to drive the game without my intervention?
A couple of hours later I had built agent-desmume, a tool to let the agent drive the game directly. It is slower than running the game real-time, so I made the agent build replay movies and scripts to set states in places of the game that were relevant to the translation.
Coming up with this tool helped me towards the tail end of the project, yet it still saved me tens of hours of mindless debugging and memory poking.
Assets - NFP
NDS shipped with a filesystem for assets, called NitroFS. In this game assets are stored in NitroFS and packed as NFP files. All the game scripts, data files, images, sounds, all bundled away in this opaque format. Unpacking NFPs was the first hurdle to get the project started.
In 2022, a modder of the community built the first approach to unpacking, which yielded some of the initial exploration of the format. It got some things wrong, but most files were unaffected and visible. So, my first sessions with the agent were about being able to repack the NFP.
I'll let the agent describe the file format:
NFP File Description
The file blocks
Think of an NFP as an obfuscated ZIP file that nobody documented. There's a small header at the front, a table of contents, and then the files themselves packed end to end. Every layer has a trick to it.
The header is a fixed 0x50-byte block. The interesting parts are a count of how many files are inside, two offsets that say where the table of contents starts and where the actual file data begins, and a pair of 32-bit seeds. Those seeds are the keys to the whole archive.
The table of contents is a list of 24-byte entries, one per file. Each entry holds a 16-byte filename, the file's offset into the archive, and a single 32-bit number we call the determinant. That one number does double duty: the top bits are the file's decompressed size, and the bottom two bits are flags. One says "this file is scrambled," the other says "this file is compressed." The table of contents itself is scrambled too, using the seeds from the header.
The files are each, potentially, scrambled and then compressed. To get a usable file back out you undo those two steps in order: unscramble, then decompress.
Step one: scrambling
The "encryption" isn't real cryptography. It's a custom XOR stream cipher straight out of the game's ARM9 code: feed it a seed, it generates a pseudo-random keystream, you XOR that against the file. XOR is its own inverse, so one routine both unscrambles and re-scrambles.
Each file's seed is a CRC32 over its 24-byte table-of-contents entry, so the filename and offset are the key. Move a file and its key moves with it.
The keystream comes out in 32-bit words, so the cipher walks the file four bytes at a time.
Step two: compression
If the "compressed" flag is set, the descrambled bytes are plain Nintendo LZ77, type 0x10, the same format the DS BIOS itself can decompress. The header is one 0x10 marker byte plus a 3-byte decompressed size, and the body is standard LZSS. GBATEK documents it and any off-the-shelf LZ10 decoder handles it, so there's nothing game-specific to reverse here. The only NFP notes: you reach this step only after descrambling, and the target size comes from those three header bytes.
So extracting one file is: slice by offset, then if scrambled XOR-descramble with its CRC key, then if it starts with 0x10 LZ10-decompress.
Putting it back together, and the determinism trap
Determinism is important in these tools, for when you need to create a patch file for the game it is done by diffing the initial and final file. If you move files around while repacking, the patch file becomes the length of the whole destination file, which means you're shipping the whole game and that's a set of legal problems we're better avoiding :)
When building the tool, we made unpacking produce the raw bytes of the file alongside its unpacked version. This, alongside the original NFP, allowed the tool to diff the changes and keep the original bytes when needed.
We needed to keep the original bytes because our LZ10 compression drifted from the original. So if you decompress a file and immediately recompress it without changing anything, you get a byte sequence that's perfectly valid, decompresses to the identical data, and yet doesn't match the original. In a normal file that's harmless. In an NFP it's fatal, because the file's offsets shift, which changes its scrambling key, which changes the table of contents, and the whole archive drifts. Early on, even a no-op rebuild bricked the ROM.
So we never recompress a file we haven't touched. The payoff is two guarantees that make the pipeline trustworthy. Rebuilding an unmodified archive reproduces it exactly, byte for byte. And editing one card table changes only that one file's bytes, plus a nudge to anything sitting after it if your edit compressed larger.
That second property is why the downloadable patch shrank from several hundred megabytes to a few hundred kilobytes once we got it right. Instead of "the whole archive looks different", the diff is just the bytes you meant to change.
The Tail Bug
This bug manifested later in the development of the patch, once I had made progress in the translation. The game would crash on certain screens when loading some of the game strings.
The technique to use here is called bisecting. You do a binary search on your problem space, partitioning it in half each time, until you find the cause or causes of the errors. So I would cook a version of the game with half of the strings translated to find the broken half. Then the broken half of that half, then half of those, until you landed on the file or lines that were broken. Each iteration halves the search space, so the number of iterations you need is roughly log2 of the problem space. With ~200 files at a median of 8 strings each, that's on the order of 2,000 candidates, and log2(2000) ~= 11 iterations to pin down a single line.
Almost all translated strings would work correctly, and I found this one string that I could easily repro with. The problem is that if I changed the string ever so slightly, the bug would not reproduce. The text included the word "starving", and if changed to "STARVING" or "hungry" or "starvin" the bug wouldn't reproduce.
This led to a most frustrating debugging session for what seemed like a heisenbug. After a few hours, I started questioning every assumption about the pipeline. At 3am the lightning finally struck. I was able to breakpoint the game's own deobfuscator and inspect the exact parameters the function received.
If a file's length isn't a clean multiple of four, there are one, two, or three bytes left over at the very end. The agent wrote the loop in increments of full 4-byte words. The real game XORs every byte. Leaving the tail unscrambled is almost invisible when you're only reading archives: the decompressor usually finishes before it ever reaches those last few bytes, so files looked fine. It only detonated when we started writing archives back. The game would descramble all the bytes, hit our garbage ones at the end, and the file would be subtly corrupt. The symptom was a cutscene that froze, but only certain cutscenes, depending on whether their packed length happened to land on a 4-byte boundary.
So, a single loop fix made weeks of random crashes disappear. I could use any wording I wanted in the translations. Or could I?
Sangokushi Taisen Ten © 2008 SEGA / ALPHA-UNIT. This is an unofficial fan project with no affiliation to SEGA or ALPHA-UNIT. All game assets belong to their respective owners.