Target: Nintendo DS / DSi homebrew Status: Implemented and verified on device (2026-08-03).
Extract ZIP archives stored on a DS flashcart's SD card, on the device, without a PC. The user picks up the console, walks the card, and extracts archives as they are found.
The search is lazy. Nothing is pre-scanned into a list; the app walks the card and stops at the first archive it finds, waits for the user, then resumes the walk from where it left off.
- Mount the SD card.
- Determine the starting directory (see Starting directory).
- Walk depth-first from there until a file matching a known archive extension is found, then pause.
- Show that archive's full path with two buttons:
- Unzip — or Enter password if the archive is encrypted
- Skip
- On Unzip, extract into a new folder beside the archive, named after it with
the extension stripped:
/games/pack.zip→/games/pack/. Report the result. - On Skip, or once extraction finishes, resume the walk from step 3.
- When the walk is exhausted, report how many archives were found, extracted and skipped.
Laziness is the point: the user sees the first result in a second or two rather than waiting for a full card scan, and memory use stays flat regardless of how many archives exist.
The button label is chosen by inspecting the archive before prompting. Bit 0 of
an entry's general-purpose flag marks it encrypted; minizip surfaces this in the
unz_file_info flag field. If any entry is encrypted, the archive is treated as
encrypted and the button reads Enter password.
The password is requested once per archive, but applied only to entries that are themselves flagged encrypted. minizip 1.1 applies decryption whenever a password is supplied — consuming 12 bytes as a crypt header regardless — so passing one to a plain entry corrupts it. Archives may legitimately mix encrypted and plain entries.
argv[0] holds the path of the running .nds when the launcher provides it,
so the app defaults to the directory it was launched from rather than making the
user type anything.
libnds populates main(int argc, char **argv) from __system_argv (see
crt0_argv.c / build_argv in libnds9.a). The struct is valid only when
__system_argv->argvMagic == ARGV_MAGIC (0x5f617267). Loaders that set it
include nds-hb-menu, hbmenu and TWiLightMenu++; booting directly from some
flashcart menus does not.
So:
| Condition | Start directory |
|---|---|
argvMagic valid and argc > 0 |
dirname(argv[0]) |
| Otherwise | Prompt the user for a path with the on-screen keyboard, defaulting to / |
Either way the user can override the path before the walk begins.
| Item | Notes |
|---|---|
| ZIP archives | Via vendored minizip 1.1. STORED and DEFLATED entries. |
| Password-protected ZIPs | PKWARE traditional encryption only. |
| Lazy recursive walk | POSIX opendir/readdir/stat over the FAT driver. |
| Touchscreen text entry | libnds built-in keyboard. |
| Item | Reason |
|---|---|
| WinZip AES ZIPs (method 99) | Separate code path absent from minizip 1.1. See minizip.md. |
| Creating or modifying archives | Read and extract only. |
| Formats other than ZIP | No 7z, RAR, tar or gzip. |
| Networking, audio | Neither is linked into the ROM. |
- A wrong password must be reported, never silently ignored.
unzCloseCurrentFile()returningUNZ_CRCERRORis the only reliable signal; the open and read calls succeed regardless. Its return value must be checked at every call site. - Extraction must never overwrite. See Collision policy.
- Entry paths inside an archive must be validated before use. An entry named
../../foowould otherwise write outside the destination folder. - A failure on one archive must not abort the walk. Report it and continue.
The ARM9 has roughly 4 MB of main RAM, with no swap and no virtual memory.
- Archive contents are streamed to disk, never buffered whole in RAM. An archive may legitimately be larger than available memory.
- The lazy walk holds one open
DIR *per level of depth. Depth must be bounded and the limit reported rather than exceeded, since each open directory costs FAT driver state. - One shared path buffer for the walk, not a
PATH_MAXbuffer per recursion level.PATH_MAXis 1024 here, and the ARM9 stack lives in 16 KB of DTCM, not in main RAM.
Abort and report. If the destination folder already exists, the archive is not extracted; the app says so and moves on to the next one.
It is the only policy that cannot destroy data, and the user can resolve it by
renaming or deleting the folder. Merging risks silent overwrites, and
suffix-incrementing (pack-2/) quietly litters the card on repeated runs.
File extension only. Matching is case-insensitive against a fixed list. Magic-byte probing was rejected because it means opening and reading every file on the card during the walk, which is exactly the cost the lazy design exists to avoid.
The consequence is accepted: a ZIP named .bin is not found, and a non-ZIP
named .zip fails at open time and is reported as a normal per-archive error.
Use the libnds built-in keyboard. Nothing is ported or hand-written.
<nds/arm9/keyboard.h> provides a complete on-screen keyboard:
| Function | Purpose |
|---|---|
keyboardDemoInit() |
One-call setup with sensible defaults |
keyboardInit() |
Full control over layer, BG type/size, map/tile base, screen |
keyboardShow() / keyboardHide() |
Visibility |
keyboardUpdate() |
Async — call once per frame, returns a char or NOKEY |
keyboardGetString() |
Blocking — reads until Enter |
keyboardGetChar() |
Blocking — single char |
keyboardModifierModeSet() |
Sticky CTRL/ALT behaviour |
Two integration constraints:
keyboardGetString()cannot be used for the password field. It echoes typed characters to the console with no masking option. Password entry must usekeyboardUpdate()in the app's own frame loop, rendering*per character. It is fine for the starting-path prompt, where echo is wanted.- The keyboard claims a background layer.
keyboardDemoInit()iskeyboardInit(NULL, 3, BgType_Text4bpp, BgSize_T_256x512, 20, 0, false, true)— layer 3, map base 20, tile base 0, on the sub screen. The app's own backgrounds must not collide with those, orkeyboardInit()must be called directly with non-conflicting values.
The only code to write is a thin wrapper: show, collect into a buffer with optional masking, handle backspace and Enter, hide, return.