
Read, modify and repack NDS ROMs; unpack/repack NARC and SDAT archives; decode STRM/SWAR to WAV, convert SSEQ to MIDI, export SF2, and handle DS compression codecs.
Kotlin Multiplatform utilities to work with .nds files
Add one or more modules to your project:
dependencies {
implementation("dev.kotlinds:nds-all:1.2.1") // All modules
implementation("dev.kotlinds:nds-narc:1.2.1") // Specific module
}val rom = NdsRom.parse(File("game.nds").readBytes())
println(rom.gameTitle) // e.g. "MY GAME"
println(rom.gameCode) // e.g. "ABCD"
println(rom.files.keys) // e.g. [a/0/0/0, a/0/0/1, ...]
// Replace files (returns a new NdsRom — original is unchanged)
val modifiedRom = rom.withFile("a/0/3/2", newFileBytes)
.withFiles(mapOf("a/0/3/3" to anotherFileBytes))
.withArm9(File("arm9_patched.bin").readBytes())
File("game_modified.nds").writeBytes(modifiedRom.pack())ARM9 and ARM7 overlays are loaded at runtime by the DS firmware. They are accessed by index, matching the order in the ROM's overlay table:
// Read overlays
val ovl0: ByteArray = rom.arm9Overlays[0]
val ovl1: ByteArray = rom.arm7Overlays[0]
// Replace an overlay (returns a new NdsRom — original is unchanged)
val modifiedRom = rom.withArm9Overlay(0, patchedOverlayBytes)
.withArm7Overlay(0, patchedArm7OverlayBytes)
File("game_modified.nds").writeBytes(modifiedRom.pack())Overlay files are typically BLZ-compressed — use BlzCodec to decompress/recompress them before patching.
NARC files bundle multiple assets inside a single ROM file. Two modes are supported:
// Anonymous (index-based) — common in most ROMs
val narcBytes = rom.files["a/0/3/2"]!!
val files: List<ByteArray> = NarcArchive.unpack(narcBytes)
val repacked: ByteArray = NarcArchive.pack(files)
// Named (path-based) — for NARCs that carry a file name table
val named: Map<String, ByteArray> = NarcArchive.unpackNamed(narcBytes)
val repackedNamed: ByteArray = NarcArchive.packNamed(
mapOf(
"sprites/player.bin" to playerData,
"sprites/enemy.bin" to enemyData,
"data/map.bin" to mapData,
)
)unpackNamed falls back to index keys ("0", "1", …) for anonymous NARCs, so it is safe to use on either type.
SDAT (Sound Data Archive) bundles all sound assets for a DS game: sequences (SSEQ), banks (SBNK), wave archives (SWAR), and streams (STRM). Each file carries metadata read directly from the INFO block.
val sdatBytes = rom.files["sound/sound_data.sdat"]!!
val archive = SdatArchive.unpack(sdatBytes)
// Access by index
val seq: SdatSseqFile = archive.sequences[0]
val bank: SdatSbnkFile = archive.banks[0]
val war: SdatSwarFile = archive.waveArchives[0]
val strm: SdatStrmFile = archive.streams[0]
println(seq.name) // e.g. "SEQ_TITLE"
println(seq.bank) // SBNK index this sequence uses
println(seq.volume) // playback volume (0–127)
println(seq.channelPriority) // hardware channel priority
println(seq.playerPriority) // sequence player priority
println(seq.players) // allowed players bitmask
println(bank.wars) // List<Int> of SWAR indices, -1 = unused slot
println(strm.volume) // stream playback volume
println(strm.priority) // stream priority
println(strm.players) // stream player bitmask
// Look up by symbolic name (from the SYMB block)
val title: SdatSseqFile? = archive.sequenceByName("SEQ_TITLE")
val drums: SdatSbnkFile? = archive.bankByName("BANK_DRUMS")
val sfx: SdatSwarFile? = archive.waveArchiveByName("WAVE_SFX")
val bgm: SdatStrmFile? = archive.streamByName("STRM_BGM")
// Repack (SYMB block is only written when at least one name differs from the fallback)
val repacked: ByteArray = SdatArchive.pack(archive)The unk field on every entry type (SdatSseqFile.unk, SdatSbnkFile.unk, SdatSwarFile.unk,
SdatStrmFile.unk) preserves the unknown u16 from the INFO struct so that round-trips are lossless.
Streams and wave archives can be decoded to standard WAV files (16-bit signed PCM). All three NDS wave encodings are supported: PCM8, PCM16, and IMA-ADPCM. Sequences can be exported to standard MIDI files.
// STRM → single WAV (may be stereo)
val wav: ByteArray = archive.streams[0].toWav()
File("bgm.wav").writeBytes(wav)
// SWAR → one WAV per instrument sample (always mono)
val wavs: List<ByteArray> = archive.waveArchives[0].toWavList()
wavs.forEachIndexed { i, w -> File("sample_$i.wav").writeBytes(w) }
// SSEQ → standard MIDI file (uses General MIDI instruments, not game sounds)
val mid: ByteArray = archive.sequences[0].toMidi()
File("bgm.mid").writeBytes(mid)A MIDI file alone uses General MIDI instruments, which don't sound like the original game. Export the sequence's instrument bank as a SoundFont 2 (SF2) file and use it together with the MIDI for authentic NDS music playback:
// SSEQ + SBNK + SWAR → SF2 instrument bank
// The convenience wrapper resolves the bank and wave archives automatically
val sf2: ByteArray = archive.sequences[0].toSf2(archive)
val mid: ByteArray = archive.sequences[0].toMidi()
File("bgm.sf2").writeBytes(sf2)
File("bgm.mid").writeBytes(mid)
// → play bgm.mid using bgm.sf2 in FluidSynth, a DAW, or any SF2-capable MIDI playerPlay from the command line with FluidSynth:
fluidsynth bgm.sf2 bgm.midIf you need the SF2 for a specific bank rather than a sequence, you can call toSf2 directly on the
bank and supply its wave archives:
val bank: SdatSbnkFile = archive.banks[0]
val wars: List<SdatSwarFile> = bank.wars
.filter { it >= 0 }
.map { archive.waveArchives[it] }
val sf2: ByteArray = bank.toSf2(wars)
File("bank.sf2").writeBytes(sf2)Most DS files use one of several compression formats identified by a magic byte. Use NdsCompression to auto-detect and
decompress:
val raw: ByteArray = NdsCompression.decompress(compressedBytes)
// Check before decompressing
if (NdsCompression.isCompressed(data)) { /* decompress */
}Each codec can also be used directly:
| Codec | Magic | Use |
|---|---|---|
LzssCodec |
0x10 |
LZ10/LZSS — most common format |
Lz11Codec |
0x11 |
LZ11 — extended lengths |
HuffmanCodec |
0x24 / 0x28
|
Huffman 4-bit / 8-bit |
RleCodec |
0x30 |
Run-length encoding |
BlzCodec |
(footer) | Bottom-LZ — ARM9 and overlays |
// All codecs share the same interface
val compressed: ByteArray = LzssCodec.compress(data)
val decompressed: ByteArray = LzssCodec.decompress(compressed)
// BLZ: arm9=true validates the secure area and skips the first 0x4000 bytes
val compressedArm9: ByteArray = BlzCodec.compress(arm9Bytes, arm9 = true)
val decompressedArm9: ByteArray = BlzCodec.decompress(compressedArm9)Note:
NdsCompressiondoes not auto-detect BLZ (it has no magic byte). UseBlzCodecdirectly for ARM9 binaries and overlay files.
.nds ROM and browse, search, and play its full soundtrack — on iOS, Android, and
desktop.If you are using kotlin-nds in your project/library, please let us know by opening a pull request to add it to this list!
Kotlin Multiplatform utilities to work with .nds files
Add one or more modules to your project:
dependencies {
implementation("dev.kotlinds:nds-all:1.2.1") // All modules
implementation("dev.kotlinds:nds-narc:1.2.1") // Specific module
}val rom = NdsRom.parse(File("game.nds").readBytes())
println(rom.gameTitle) // e.g. "MY GAME"
println(rom.gameCode) // e.g. "ABCD"
println(rom.files.keys) // e.g. [a/0/0/0, a/0/0/1, ...]
// Replace files (returns a new NdsRom — original is unchanged)
val modifiedRom = rom.withFile("a/0/3/2", newFileBytes)
.withFiles(mapOf("a/0/3/3" to anotherFileBytes))
.withArm9(File("arm9_patched.bin").readBytes())
File("game_modified.nds").writeBytes(modifiedRom.pack())ARM9 and ARM7 overlays are loaded at runtime by the DS firmware. They are accessed by index, matching the order in the ROM's overlay table:
// Read overlays
val ovl0: ByteArray = rom.arm9Overlays[0]
val ovl1: ByteArray = rom.arm7Overlays[0]
// Replace an overlay (returns a new NdsRom — original is unchanged)
val modifiedRom = rom.withArm9Overlay(0, patchedOverlayBytes)
.withArm7Overlay(0, patchedArm7OverlayBytes)
File("game_modified.nds").writeBytes(modifiedRom.pack())Overlay files are typically BLZ-compressed — use BlzCodec to decompress/recompress them before patching.
NARC files bundle multiple assets inside a single ROM file. Two modes are supported:
// Anonymous (index-based) — common in most ROMs
val narcBytes = rom.files["a/0/3/2"]!!
val files: List<ByteArray> = NarcArchive.unpack(narcBytes)
val repacked: ByteArray = NarcArchive.pack(files)
// Named (path-based) — for NARCs that carry a file name table
val named: Map<String, ByteArray> = NarcArchive.unpackNamed(narcBytes)
val repackedNamed: ByteArray = NarcArchive.packNamed(
mapOf(
"sprites/player.bin" to playerData,
"sprites/enemy.bin" to enemyData,
"data/map.bin" to mapData,
)
)unpackNamed falls back to index keys ("0", "1", …) for anonymous NARCs, so it is safe to use on either type.
SDAT (Sound Data Archive) bundles all sound assets for a DS game: sequences (SSEQ), banks (SBNK), wave archives (SWAR), and streams (STRM). Each file carries metadata read directly from the INFO block.
val sdatBytes = rom.files["sound/sound_data.sdat"]!!
val archive = SdatArchive.unpack(sdatBytes)
// Access by index
val seq: SdatSseqFile = archive.sequences[0]
val bank: SdatSbnkFile = archive.banks[0]
val war: SdatSwarFile = archive.waveArchives[0]
val strm: SdatStrmFile = archive.streams[0]
println(seq.name) // e.g. "SEQ_TITLE"
println(seq.bank) // SBNK index this sequence uses
println(seq.volume) // playback volume (0–127)
println(seq.channelPriority) // hardware channel priority
println(seq.playerPriority) // sequence player priority
println(seq.players) // allowed players bitmask
println(bank.wars) // List<Int> of SWAR indices, -1 = unused slot
println(strm.volume) // stream playback volume
println(strm.priority) // stream priority
println(strm.players) // stream player bitmask
// Look up by symbolic name (from the SYMB block)
val title: SdatSseqFile? = archive.sequenceByName("SEQ_TITLE")
val drums: SdatSbnkFile? = archive.bankByName("BANK_DRUMS")
val sfx: SdatSwarFile? = archive.waveArchiveByName("WAVE_SFX")
val bgm: SdatStrmFile? = archive.streamByName("STRM_BGM")
// Repack (SYMB block is only written when at least one name differs from the fallback)
val repacked: ByteArray = SdatArchive.pack(archive)The unk field on every entry type (SdatSseqFile.unk, SdatSbnkFile.unk, SdatSwarFile.unk,
SdatStrmFile.unk) preserves the unknown u16 from the INFO struct so that round-trips are lossless.
Streams and wave archives can be decoded to standard WAV files (16-bit signed PCM). All three NDS wave encodings are supported: PCM8, PCM16, and IMA-ADPCM. Sequences can be exported to standard MIDI files.
// STRM → single WAV (may be stereo)
val wav: ByteArray = archive.streams[0].toWav()
File("bgm.wav").writeBytes(wav)
// SWAR → one WAV per instrument sample (always mono)
val wavs: List<ByteArray> = archive.waveArchives[0].toWavList()
wavs.forEachIndexed { i, w -> File("sample_$i.wav").writeBytes(w) }
// SSEQ → standard MIDI file (uses General MIDI instruments, not game sounds)
val mid: ByteArray = archive.sequences[0].toMidi()
File("bgm.mid").writeBytes(mid)A MIDI file alone uses General MIDI instruments, which don't sound like the original game. Export the sequence's instrument bank as a SoundFont 2 (SF2) file and use it together with the MIDI for authentic NDS music playback:
// SSEQ + SBNK + SWAR → SF2 instrument bank
// The convenience wrapper resolves the bank and wave archives automatically
val sf2: ByteArray = archive.sequences[0].toSf2(archive)
val mid: ByteArray = archive.sequences[0].toMidi()
File("bgm.sf2").writeBytes(sf2)
File("bgm.mid").writeBytes(mid)
// → play bgm.mid using bgm.sf2 in FluidSynth, a DAW, or any SF2-capable MIDI playerPlay from the command line with FluidSynth:
fluidsynth bgm.sf2 bgm.midIf you need the SF2 for a specific bank rather than a sequence, you can call toSf2 directly on the
bank and supply its wave archives:
val bank: SdatSbnkFile = archive.banks[0]
val wars: List<SdatSwarFile> = bank.wars
.filter { it >= 0 }
.map { archive.waveArchives[it] }
val sf2: ByteArray = bank.toSf2(wars)
File("bank.sf2").writeBytes(sf2)Most DS files use one of several compression formats identified by a magic byte. Use NdsCompression to auto-detect and
decompress:
val raw: ByteArray = NdsCompression.decompress(compressedBytes)
// Check before decompressing
if (NdsCompression.isCompressed(data)) { /* decompress */
}Each codec can also be used directly:
| Codec | Magic | Use |
|---|---|---|
LzssCodec |
0x10 |
LZ10/LZSS — most common format |
Lz11Codec |
0x11 |
LZ11 — extended lengths |
HuffmanCodec |
0x24 / 0x28
|
Huffman 4-bit / 8-bit |
RleCodec |
0x30 |
Run-length encoding |
BlzCodec |
(footer) | Bottom-LZ — ARM9 and overlays |
// All codecs share the same interface
val compressed: ByteArray = LzssCodec.compress(data)
val decompressed: ByteArray = LzssCodec.decompress(compressed)
// BLZ: arm9=true validates the secure area and skips the first 0x4000 bytes
val compressedArm9: ByteArray = BlzCodec.compress(arm9Bytes, arm9 = true)
val decompressedArm9: ByteArray = BlzCodec.decompress(compressedArm9)Note:
NdsCompressiondoes not auto-detect BLZ (it has no magic byte). UseBlzCodecdirectly for ARM9 binaries and overlay files.
.nds ROM and browse, search, and play its full soundtrack — on iOS, Android, and
desktop.If you are using kotlin-nds in your project/library, please let us know by opening a pull request to add it to this list!