
Wil je je eigen homebrewgame voor de Nintendo 3DS maken? Goed nieuws: dat kan nog steeds, en het is een ontzettend leuk project. In deze tutorial deel ik alles wat ik heb geleerd tijdens het bouwen van 2048 voor Nintendo 3DS, een complete homebrewgame met animaties, audio, 13 talen en achievements, van de eerste regel code tot een installeerbaar .cia-bestand.
Bekijk het eindresultaat: 2048 voor Nintendo 3DS, gratis te downloaden als .3dsx en .cia.
Deze gids behandelt de complete pipeline van 3DS-homebrewontwikkeling: 2D-graphics met citro2d, audio via NDSP, touchscreen, het bestandssysteem RomFS en packaging als CIA. Alle code is C99. Geen theoretisch gezwam, alleen code die echt draait, rechtstreeks uit het project 2048-3DS.
Inhoud
- Wat is een 3DS-homebrew
- Vereisten: devkitARM en de 3DS-ontwikkeltools installeren
- Opbouw van een homebrewproject: logica en rendering scheiden
- De twee schermen van de Nintendo 3DS aansturen
- 2D-graphics renderen met citro2d
- Input: D-pad, circle pad en touchscreen
- Audio in 3DS-homebrew: NDSP en WAV PCM
- Bestandssysteem: RomFS en SD-kaart
- Data opslaan op de SD-kaart
- De 3DS-Makefile: ARM-cross-compilatie met devkitARM
- Een .3dsx-bestand bouwen voor de Homebrew Launcher
- Een installeerbaar .cia-bestand bouwen voor het HOME-menu
- Valkuilen en veelgemaakte fouten bij 3DS-homebrew
- Conclusie en bronnen om verder te gaan
1. Wat is een Nintendo 3DS-homebrew?
Een 3DS-homebrew is een niet-officiële applicatie die draait op een Nintendo 3DS met custom firmware (CFW) zoals Luma3DS. Dankzij de 3DS-homebrewscene kun je vrij games, emulators en tools voor deze handheld maken en verspreiden.
De Nintendo 3DS heeft een ARM11-processor (ARMv6K) op 268 MHz en een PICA200-GPU, met één bijzonderheid: twee schermen. Het bovenste scherm toont 400×240 pixels (800×240 in stereoscopisch 3D), het onderste, met touch, 320×240 pixels.
Er zijn twee distributieformaten voor 3DS-homebrew:
- .3dsx: het homebrewformaat dat je start via de Homebrew Launcher. Geen installatie, draait rechtstreeks vanaf de SD-kaart.
- .cia: het installeerbare formaat dat in het HOME-menu van de 3DS verschijnt, met eigen icoon en geanimeerde banner. Vereist custom firmware (CFW).
Voor 2048-3DS bouw ik beide formaten, zodat ik zoveel mogelijk spelers bereik. De 3DS-homebrewscene leeft nog volop dankzij een solide toolset: devkitARM (toolchain), libctru (systeembibliotheek) en citro2d/citro3d (graphics). Zoek je een goede speeltuin om embedded systeemprogrammeren of retro gamedevelopment te leren, dan is de 3DS daar perfect voor.
2. Vereisten: devkitARM en de 3DS-ontwikkeltools installeren
Voordat je begint met een game programmeren voor de Nintendo 3DS, installeer je de toolchain voor ARM-cross-compilatie plus de bibliotheken van de console.
devkitARM: de compiler voor de Nintendo 3DS
devkitARM is de GCC-toolchain voor cross-compilatie naar ARM-processors, geleverd door het project devkitPro. Die bevat de compiler, de linker en de buildtools die je nodig hebt om C-code voor de 3DS te compileren. Zo installeer je hem:
# Installatie op Linux/WSL (aanbevolen voor 3DS-ontwikkeling)
wget https://apt.devkitpro.org/install-devkitpro-pacman
chmod +x install-devkitpro-pacman
sudo ./install-devkitpro-pacman
# De 3DS-ontwikkelpakketten installeren
sudo dkp-pacman -S 3ds-dev
# Verplichte omgevingsvariabele
export DEVKITARM=/opt/devkitpro/devkitARMEssentiële bibliotheken voor 3DS-homebrewontwikkeling
- libctru: low-level C-bibliotheek voor de systeemservices van de 3DS (HID, filesystem, audio, GPU). De basis onder elk 3DS-homebrewprogramma.
- citro3d: abstractielaag boven de PICA200-GPU. Regelt de render targets, de framebuffer en de grafische synchronisatie.
- citro2d: 2D-laag boven citro3d. Levert eenvoudige primitives (rechthoeken, cirkels, ellipsen, lijnen, tekst, sprites), ideaal voor 2D-games op de 3DS.
Extra tools voor het packagen
- bannertool: genereert de bestanden banner.bin en icon.bin die het CIA-formaat nodig heeft
- makerom: zet het uiteindelijke .cia-bestand samen uit de ELF, het RSF, de banner en het icoon
- tex3ds: zet PNG-afbeeldingen om in .t3x-textures die citro2d op de PICA200-GPU kan lezen
- mkbcfnt: zet TTF-fonts om naar .bcfnt voor tekstrendering op de 3DS (ondersteunt Unicode, CJK en cyrillisch)
3. Opbouw van een 3DS-homebrewproject: logica en rendering scheiden
De klassieke valkuil als je begint met homebrewontwikkeling: alle code meteen bovenop de API’s van de console schrijven. Gevolg: testen of debuggen wordt onmogelijk. Ik ben daar zelf ingetrapt, en mijn advies aan iedereen is om de spellogica vanaf dag één van de rendering te scheiden.
Precies die aanpak gebruik ik in 2048-3DS: de spellogica van 2048 (grid, bewegingen, samenvoegingen, score) is 100% portable, terwijl de rendering per platform verschilt.
Bestandsstructuur van het project 2048-3DS
2048-3ds/
├── source/
│ ├── logic.h # Interface du jeu 2048 (portable, sans dépendance)
│ ├── logic.c # Logique du jeu (grille 4x4, mouvements, score)
│ ├── achievements.h # Système de succès (8 paliers de score)
│ ├── achievements.c # Sauvegarde/chargement binaire des succès
│ ├── lang.h # Localisation (13 langues, compile-time)
│ ├── main_sdl.c # Rendu PC avec SDL2 (simulation double écran)
│ └── main_3ds.c # Rendu Nintendo 3DS avec citro2d
├── romfs/ # Assets embarqués dans le binaire .3dsx/.cia
│ ├── font.bcfnt # Police custom avec glyphes CJK/cyrillique
│ ├── sprites.t3x # Sprite sheet compile (icônes de succès)
│ └── music/ # Musique et effets sonores (WAV PCM obligatoire)
├── assets/ # Ressources PC + assets pour bannière CIA
├── gfx/ # Sources des sprites (PNG + config .t3s)
├── Makefile # Build PC
├── Makefile.3ds # Build 3DS (devkitARM + citro2d)
└── app.rsf # Configuration CIA (permissions, titre, UniqueId)Portable code: de 2048-logica zonder dependencies
Het bestand logic.c van 2048-3DS includeert geen enkele 3DS-specifieke header. Het gebruikt alleen standaard C-headers (<stdint.h>, <stdlib.h>, <string.h>, <time.h>). Het bestand main_3ds.c roept de publieke functies van die logica aan:
// logic.h — draagbare interface van het spel 2048
#define GRID_SIZE 4
#define MAX_TILE_ANIMS 16
typedef enum { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT } Direction;
typedef struct {
int from_r, from_c; // positie voor de zet
int to_r, to_c; // positie na de zet
uint16_t value; // waarde die tijdens de animatie wordt getoond
int merged; // 1 bij een samenvoeging
} TileAnim;
typedef struct {
uint16_t cells[GRID_SIZE][GRID_SIZE]; // 4x4-grid
uint32_t score;
int won, over;
TileAnim anims[MAX_TILE_ANIMS]; // animatiedata van de laatste zet
int anim_count;
int spawn_r, spawn_c; // positie van de nieuwe tegel
uint16_t spawn_val; // waarde (2 in 90% van de gevallen, 4 in 10%)
} Game;
void game_init(Game *g); // leeg grid + 2 tegels
int game_move(Game *g, Direction dir); // geeft 1 terug als het grid is veranderd
int game_is_over(Game *g); // geen enkele zet meer mogelijk
int game_has_won(Game *g); // tegel >= 2048 bereiktDe 3DS-Makefile compileert logic.c + achievements.c + main_3ds.c en filtert main_sdl.c er expliciet uit. De spellogica blijft identiek, ongeacht de renderfront-end.
4. De twee schermen van de Nintendo 3DS aansturen
Wat de Nintendo 3DS onderscheidt van elke andere handheld is het dubbele scherm. Daar goed gebruik van maken is essentieel voor een prettige homebrewervaring.
- Bovenste scherm (top): 400×240 pixels, geen touch. Ideaal voor de eigenlijke gameplay. In 2048-3DS teken ik hier het 4×4-grid met de tegelanimaties.
- Onderste scherm (bottom): 320×240 pixels, met touch. Ideaal voor de interface, de menu’s en de bediening. In 2048-3DS staan daar de score, de knoppen, de instellingen en de achievements.
Render targets initialiseren met citro2d
// Het grafische systeem van de 3DS initialiseren
gfxInitDefault();
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
C2D_Prepare();
// Een render target maken voor elk scherm
C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);Renderloop voor twee schermen
Bij elke frame teken je de twee schermen apart. Dit is de structuur van de renderloop uit 2048-3DS:
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);
// Bovenste scherm — het 2048-grid
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_top_screen(&game, anim_phase, progress);
// Onderste scherm — UI (score, knoppen, instellingen)
C2D_TargetClear(bot, col_bot_bg);
C2D_SceneBegin(bot);
render_bot_game(&game, best_score, 0, music_muted);
C3D_FrameEnd(0);Kritieke valkuil bij 3DS-rendering: C2D_TargetClear is verplicht. Roep je
C2D_TargetClear()niet aan voor elke scène, dan krijg je willekeurige beeldartefacten, resten in het VRAM van de vorige frame. Het VRAM van de 3DS wordt niet automatisch gewist. Die bug brengt beginnende homebrewbouwers flink in verwarring, omdat de artefacten per frame veranderen.
Schermafmetingen: de constanten die je nodig hebt
#define TOP_W 400 // breedte bovenste scherm
#define TOP_H 240 // hoogte bovenste scherm
#define BOT_W 320 // breedte onderste scherm (touch)
#define BOT_H 240 // hoogte onderste schermLet op: op de console zelf staat het onderste scherm horizontaal gecentreerd onder het bovenste (400 tegenover 320 pixels breed). De touchcoördinaten komen rechtstreeks overeen met de pixels van het onderste scherm, met (0,0) linksboven.
5. 2D-graphics renderen met citro2d op de Nintendo 3DS
citro2d is dé 2D-renderbibliotheek voor 3DS-homebrewgames. Gebouwd bovenop citro3d en de PICA200-GPU, regelt ze intern het batchen van primitives en het beheer van de GPU-textures. Zo gebruik je haar, met concrete voorbeelden uit 2048-3DS.
Primitives tekenen: rechthoeken, cirkels, ellipsen en lijnen
// Gevulde rechthoek (tegels van 2048)
C2D_DrawRectSolid(x, y, z, largeur, hauteur, couleur);
// Gevulde cirkel (afgeronde hoeken, iconen)
C2D_DrawCircleSolid(centre_x, centre_y, z, rayon, couleur);
// Gevulde ellips (muziekicoon in 2048-3DS)
C2D_DrawEllipseSolid(x, y, z, largeur, hauteur, couleur);
// Lijn (mute-streep voor de audio)
C2D_DrawLine(x1, y1, couleur1, x2, y2, couleur2, epaisseur, z);De parameter z bepaalt de diepte (0.0f voor het standaardvlak). Kleuren staan op de 3DS in het formaat ABGR in het geheugen (let op: precies andersom dan het klassieke ARGB). citro2d levert de macro C2D_Color32(r, g, b, a) om kleuren correct op te bouwen:
// C99-valkuil: C2D_Color32() is geen constexpr
// Gebruik een macro voor globale kleurconstanten
#define MAKE_COLOR(r,g,b,a) \
((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))
// Kleurenpalet van 2048 op de 3DS
#define col_grid_bg MAKE_COLOR(0xBB, 0xAD, 0xA0, 0xFF) // achtergrond van het grid
#define col_bot_bg MAKE_COLOR(0xFA, 0xF8, 0xEF, 0xFF) // achtergrond onderste scherm
#define col_text_dk MAKE_COLOR(0x77, 0x6E, 0x65, 0xFF) // donkere tekst
#define col_text_lt MAKE_COLOR(0xF9, 0xF6, 0xF2, 0xFF) // lichte tekstAfgeronde rechthoeken: een grafische truc voor citro2d
citro2d heeft geen primitive voor een “afgeronde rechthoek”. Voor de tegels van 2048 en de knoppen in de interface heb ik deze techniek bedacht: een centrale rechthoek, twee rechthoeken aan de zijkanten en vier cirkels in de hoeken:
static void fill_rounded_rect(float x, float y, float w, float h,
float r, u32 clr)
{
if (r < 1.0f || w < 2*r || h < 2*r) {
C2D_DrawRectSolid(x, y, 0.0f, w, h, clr);
return;
}
// Middenstuk + zijranden
C2D_DrawRectSolid(x + r, y, 0.0f, w - 2*r, h, clr);
C2D_DrawRectSolid(x, y + r, 0.0f, r, h - 2*r, clr);
C2D_DrawRectSolid(x + w - r, y + r, 0.0f, r, h - 2*r, clr);
// 4 cirkels met straal r in de hoeken
C2D_DrawCircleSolid(x + r, y + r, 0.0f, r, clr);
C2D_DrawCircleSolid(x + w - r, y + r, 0.0f, r, clr);
C2D_DrawCircleSolid(x + r, y + h - r, 0.0f, r, clr);
C2D_DrawCircleSolid(x + w - r, y + h - r, 0.0f, r, clr);
}Die functie zit overal in 2048-3DS: speeltegels, scorevakjes, knoppen, volumesliders en bevestigingsdialogen.
Tekst weergeven op de Nintendo 3DS met .bcfnt
Tekstrendering op de 3DS loopt via het formaat .bcfnt (Binary CTR Font). Elke string moet eerst in een tekstbuffer worden ‘geparsed’ voordat je hem tekent. In 2048-3DS gebruik ik dat voor de scores, de knopteksten in 13 talen en de achievementmeldingen:
// De tekstbuffers initialiseren
C2D_TextBuf s_dynamicBuf = C2D_TextBufNew(512);
C2D_Font s_font = C2D_FontLoad("romfs:/font.bcfnt");
// Hulpfunctie: gecentreerde tekst tekenen
static void draw_text_centered(const char *str, float cx, float cy,
float scale, u32 color)
{
C2D_Text text;
C2D_TextFontParse(&text, s_font, s_dynamicBuf, str);
C2D_TextOptimize(&text);
float w, h;
C2D_TextGetDimensions(&text, scale, scale, &w, &h);
C2D_DrawText(&text, C2D_WithColor,
cx - w / 2, cy - h * 0.62f,
0.0f, scale, scale, color);
}
// Belangrijk: leeg de tekstbuffer bij elke frame
C2D_TextBufClear(s_dynamicBuf);De factor 0.62f is afgestemd op een visueel correcte verticale centrering van de tekst.
Sprites en GPU-textures in .t3x-formaat
Afbeeldingen moet je met de tool tex3ds omzetten naar het formaat .t3x (3DS-texture, geoptimaliseerd voor de PICA200-GPU). Een .t3s-bestand beschrijft de sprite sheet. In 2048-3DS zitten de 8 achievementiconen (elk 32×32 pixels) in één sprite sheet:
# gfx/sprites.t3s — configuration du sprite sheet pour tex3ds
--atlas -f rgba8888 -z auto
sprite_0.png # Debutant (vert)
sprite_1.png # Apprenti (bleu)
sprite_2.png # Competent (violet)
sprite_3.png # Expert (orange)
sprite_4.png # Maitre (rouge)
sprite_5.png # Grand Maitre (rose)
sprite_6.png # Legende (or)
sprite_7.png # Titan (or brillant)// De sprite sheet laden vanuit RomFS
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");
// Een achievementicoon tekenen
C2D_Image img = C2D_SpriteSheetGetImage(sheet, index);
C2D_DrawImageAt(img, x, y, 0.0f, NULL, scale_x, scale_y);
// Weergave in grijstinten (achievement nog vergrendeld)
C2D_ImageTint tint;
C2D_PlainImageTint(&tint, C2D_Color32(128, 128, 128, 255), 1.0f);
C2D_DrawImageAt(img, x, y, 0.0f, &tint, scale_x, scale_y);Vloeiende animaties op de Nintendo 3DS
Voor vloeiende animaties in een 3DS-homebrewgame gebruik je osGetTime() (tijd in milliseconden) en interpolatie met easing. Dit is het animatiesysteem uit 2048-3DS voor het schuiven en verschijnen van tegels:
// Timing van de animaties in 2048
#define ANIM_SLIDE_MS 120 // duur van het schuiven van een tegel
#define ANIM_POP_MS 100 // duur van de "pop" (samenvoegen/verschijnen)
typedef enum { ANIM_NONE, ANIM_SLIDING, ANIM_POPPING } AnimPhase;
// Kwadratische easing: natuurlijk afremmen
static float ease_out_quad(float t) {
return t * (2.0f - t);
}
// In de hoofdloop: de posities interpoleren
u64 now = osGetTime();
u64 elapsed = now - anim_start;
float progress = (float)elapsed / ANIM_SLIDE_MS;
float t = ease_out_quad(progress);
// Lineair schuiven van de tegel
float cur_x = from_px + (to_px - from_px) * t;
float cur_y = from_py + (to_py - from_py) * t;De animatie verloopt in twee fasen: eerst het schuiven (120 ms), daarna de “pop”, samengevoegde tegels groeien naar 125% en gaan terug naar 100%, en de nieuwe tegel groeit van 0 naar 100%. Daardoor voelt 2048 op de 3DS visueel net zo bevredigend als het origineel.
6. Input op de Nintendo 3DS: D-pad, circle pad en touchscreen
De Nintendo 3DS biedt homebrewgames meerdere invoerapparaten, allemaal bereikbaar via libctru (HID). In 2048-3DS gebruik ik ze alle drie: D-pad en circle pad om tegels te verplaatsen, het touchscreen voor knoppen en sliders.
De fysieke knoppen lezen: D-pad en A/B/X/Y
hidScanInput(); // De inputstatus uitlezen (1x per frame)
u32 kDown = hidKeysDown(); // Knoppen die deze frame nieuw zijn ingedrukt
// D-pad: de tegels in 2048 verplaatsen
if (kDown & KEY_DUP) { dir = DIR_UP; do_move = 1; }
if (kDown & KEY_DDOWN) { dir = DIR_DOWN; do_move = 1; }
if (kDown & KEY_DLEFT) { dir = DIR_LEFT; do_move = 1; }
if (kDown & KEY_DRIGHT) { dir = DIR_RIGHT; do_move = 1; }
// Systeemknoppen
if (kDown & KEY_B) { /* terug navigeren */ }
if (kDown & KEY_START) { break; /* de applicatie afsluiten */ }
if (kDown & KEY_SELECT) { game_init(&game); /* nieuw spel */ }Circle Pad: de analoge stick van de 3DS met deadzone
circlePosition cpad;
hidCircleRead(&cpad);
// Deadzone van 80 om valse positieven te vermijden
// Bereik van de circle pad: ongeveer -155 tot +155
if (cpad.dy > 80) { dir = DIR_UP; do_move = 1; }
if (cpad.dy < -80) { dir = DIR_DOWN; do_move = 1; }
if (cpad.dx < -80) { dir = DIR_LEFT; do_move = 1; }
if (cpad.dx > 80) { dir = DIR_RIGHT; do_move = 1; }Een deadzone is cruciaal voor de circle pad van de 3DS. Zonder drempel registreert de stick voortdurend microbewegingen en krijg je zetten die je niet wilde. Een waarde van 80 (op een bereik van ongeveer -155 tot +155) werkt in de praktijk prima voor een game als 2048.
Touchscreen: kliks op de interfaceknoppen afhandelen
Het touchscreen van de 3DS is resistief (druk, niet capacitief). De coördinaten zijn rechtstreeks pixels van het onderste scherm (320×240). In 2048-3DS regelt het touchscreen de knoppen “Nieuw spel”, “Achievements” en “Instellingen”, de volumesliders en de taalkeuze:
if (kDown & KEY_TOUCH) {
touchPosition touch;
hidTouchRead(&touch);
int mx = touch.px; // 0..319
int my = touch.py; // 0..239
// Klik op de knop "Nieuw spel" detecteren
float btn_x = (BOT_W - BTN_W) / 2; // horizontaal gecentreerd
float btn_y = 112;
if (mx >= btn_x && mx <= btn_x + BTN_W &&
my >= btn_y && my <= btn_y + BTN_H) {
game_init(&game);
anim_phase = ANIM_NONE;
}
}Gebruik hidKeysDown() met KEY_TOUCH voor losse taps (knoppen) en hidKeysHeld() voor doorlopende interacties (volumesliders, slepen).
De hoofdloop van een 3DS-homebrewgame
while (aptMainLoop()) {
u64 now = osGetTime();
audio_music_tick(music_volume, music_muted);
hidScanInput();
u32 kDown = hidKeysDown();
if (kDown & KEY_START) break; // Nette afsluiting
// Input verwerken (D-pad, circle pad, touch)
// De spellogica van 2048 bijwerken
// De twee schermen renderen
// ...
}aptMainLoop() regelt de levenscyclus van de homebrewapplicatie: slaapstand, afsluiten door het systeem en terugkeren naar het HOME-menu.
7. Audio in 3DS-homebrew: NDSP en WAV PCM
Geloof me: audio op de Nintendo 3DS is het onderdeel waar ik de meeste tijd aan kwijt was. Op papier lijkt alles simpel, maar de valkuilen liggen overal. De audiobackend van de 3DS heet NDSP (Nintendo DSP): die heeft de DSP-firmware nodig (dspfirm.bin op de SD-kaart) en ondersteunt mixen over meerdere kanalen met lineaire interpolatie.
In 2048-3DS heb ik achtergrondmuziek over 4 tracks gezet, plus geluidseffecten voor de tegelbewegingen en de achievements.
Audioformaat voor de 3DS: WAV PCM verplicht
Grote valkuil bij 3DS-audio: de console ondersteunt alleen ruwe WAV PCM (geen MP3, geen OGG, geen met ADPCM gecomprimeerde WAV). De bestanden moeten PCM 8 of 16 bit zijn, mono of stereo. Elk ander formaat wordt stilletjes genegeerd of veroorzaakt een crash.
Geheugen voor 3DS-audio: linearAlloc in plaats van malloc
Audiobuffers op de 3DS moet je alloceren met linearAlloc() en niet met malloc(). Lineair geheugen is rechtstreeks bereikbaar voor de DSP-processor, de gewone heap niet.
// Structuur om een WAV-audiobestand op de 3DS te bewaren
typedef struct {
u8 *data; // PCM-data (gealloceerd met linearAlloc)
u32 size; // Grootte in bytes
u32 sample_rate; // Samplefrequentie
u16 channels; // 1 (mono) of 2 (stereo)
u16 bits_per_sample; // 8 of 16 bit
ndspWaveBuf wave_buf; // NDSP-buffer
int loaded; // Vlag: laden geslaagd
} WavSound;WAV-bestanden laden in 3DS-homebrew
static int wav_load(WavSound *snd, const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) return -1;
// De RIFF/WAVE-header lezen en valideren
char riff[4]; u32 file_size; char wave[4];
fread(riff, 1, 4, f);
fread(&file_size, 4, 1, f);
fread(wave, 1, 4, f);
if (memcmp(riff, "RIFF", 4) != 0 ||
memcmp(wave, "WAVE", 4) != 0) {
fclose(f); return -1;
}
// De WAV-chunks doorlopen (fmt + data)
while (!got_data) {
char chunk_id[4]; u32 chunk_size;
fread(chunk_id, 1, 4, f);
fread(&chunk_size, 4, 1, f);
if (memcmp(chunk_id, "fmt ", 4) == 0) {
// channels, sample_rate en bits_per_sample uitlezen
// ...
} else if (memcmp(chunk_id, "data", 4) == 0) {
// BELANGRIJK: linearAlloc, geen malloc!
snd->data = (u8 *)linearAlloc(chunk_size);
fread(snd->data, 1, chunk_size, f);
} else {
fseek(f, chunk_size, SEEK_CUR);
}
}
// De NDSP-buffer klaarzetten
snd->wave_buf.data_vaddr = snd->data;
snd->wave_buf.nsamples = snd->size /
(snd->channels * snd->bits_per_sample / 8);
snd->wave_buf.looping = false;
// VERPLICHT: de CPU-cache naar de DSP flushen
DSP_FlushDataCache(snd->data, snd->size);
return 0;
}DSP_FlushDataCache is verplicht na elke schrijfactie in een 3DS-audiobuffer. Zonder die aanroep leest de DSP corrupte data, omdat de CPU-cache niet coherent is met het DSP-geheugen. Dit is oorzaak nummer 1 van stille audiobugs op de Nintendo 3DS.
NDSP-audio initialiseren
Dit is de audio-initialisatie uit 2048-3DS:
static void audio_init(void)
{
if (ndspInit() != 0) return;
ndspSetOutputMode(NDSP_OUTPUT_STEREO);
// Kanaal 0: achtergrondmuziek
// Kanaal 1: SFX (tegelbeweging)
// Kanaal 2: SFX (achievement vrijgespeeld)
for (int ch = 0; ch < 3; ch++) {
ndspChnReset(ch);
ndspChnSetInterp(ch, NDSP_INTERP_LINEAR);
ndspChnSetFormat(ch, NDSP_FORMAT_STEREO_PCM16);
}
}Geluid afspelen en het volume regelen op de 3DS
// NDSP: de audiobuffer aan het kanaal toevoegen
snd->wave_buf.status = NDSP_WBUF_FREE; // de status resetten
DSP_FlushDataCache(snd->data, snd->size);
ndspChnWaveBufAdd(channel, &snd->wave_buf);
// NDSP-volume: array van 12 floats (stereomix per kanaal)
float vol = (float)music_volume / 128.0f; // 0..128 -> 0.0..1.0
float mix[12] = {0};
mix[0] = vol; // linkerkanaal
mix[1] = vol; // rechterkanaal
ndspChnSetMix(0, mix);Muziektracks automatisch achter elkaar afspelen
In 2048-3DS volgen 4 muziektracks elkaar automatisch op (intro, music1, music2, music3). Bij elke frame controleer ik of de huidige track is afgelopen:
static void audio_music_tick(int music_volume, int music_muted)
{
if (!s_audio_init || music_muted) return;
if (!ndspChnIsPlaying(0)) {
// Naar de volgende track (cyclisch)
s_music_current = (s_music_current + 1) % MUSIC_TRACK_COUNT;
audio_play_current_track(music_volume, music_muted);
}
}Audio opruimen aan het einde van het programma
// De audiobronnen netjes vrijgeven
ndspChnReset(0);
ndspChnReset(1);
ndspChnReset(2);
ndspExit();
// Het lineaire geheugen vrijgeven (geen free, maar linearFree!)
for (int i = 0; i < MUSIC_TRACK_COUNT; i++)
linearFree(s_music[i].data);
linearFree(s_sfx_push.data);
linearFree(s_sfx_ach.data);8. Het bestandssysteem van de 3DS: RomFS en SD-kaart
De Nintendo 3DS biedt homebrewapplicaties twee bestandssystemen: RomFS voor read-only assets en SDMC om te lezen en schrijven op de SD-kaart.
RomFS: assets in de homebrewbinary meebakken
RomFS (Read-Only Memory FileSystem) laat je bestanden rechtstreeks in de .3dsx- of .cia-binary meebakken. Ideaal voor assets die niet veranderen: fonts, textures, muziek, sprites. In 2048-3DS bevat de map romfs/ het .bcfnt-font met CJK-ondersteuning, de sprite sheet met de achievements en 6 WAV-bestanden:
// Verplichte initialisatie vóór elke RomFS-toegang
romfsInit();
// Assets benaderen met de prefix romfs:/
C2D_Font font = C2D_FontLoad("romfs:/font.bcfnt");
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");
wav_load(&s_music[0], "romfs:/music/intro.wav");De inhoud van de map romfs/ wordt automatisch meegenomen door de 3DS-Makefile:
# In Makefile.3ds
ROMFS := romfsAssetformaten die eigen zijn aan de Nintendo 3DS
- .bcfnt: bitmapfont gecompileerd met
mkbcfnt. Ondersteunt volledige Unicode (CJK, cyrillisch, letters met accenten). In 2048-3DS dekt één font alle 13 talen. - .t3x: GPU-texture gecompileerd met
tex3ds. Geoptimaliseerd voor de PICA200. Laadt zonder conversie rechtstreeks in het VRAM. - .wav: audio in ruwe PCM. Geen conversie tijdens runtime, maar het moet ongecomprimeerde PCM zijn (zie de audiosectie).
SDMC: data opslaan op de SD-kaart van de 3DS
Om data op te slaan (scores, instellingen, voortgang) schrijft 3DS-homebrew naar de SD-kaart met de prefix sdmc:/:
// Savepaden voor 2048-3DS
#define SAVE_DIR "sdmc:/3ds/2048/"
#define ACH_SAVE_PATH "sdmc:/3ds/2048/achievements.dat"
#define SETTINGS_SAVE_PATH "sdmc:/3ds/2048/settings.dat"
// De savemap aanmaken bij het opstarten
#include <sys/stat.h>
mkdir("sdmc:/3ds", 0777);
mkdir(SAVE_DIR, 0777);Het schrijven gebeurt met de standaard C-functies (fopen, fwrite, fclose). Voor bestandsbewerkingen op SDMC heb je geen 3DS-specifieke API nodig.
9. Data opslaan op de SD-kaart in een 3DS-homebrew
Voor een 3DS-homebrewgame is binair opslaan de meest directe en zuinigste methode. Zo regelt 2048-3DS de persistentie van de achievements en de instellingen.
Het achievementsysteem binair opslaan
2048-3DS heeft 8 achievements (scoredrempels: 500, 1000, 2500, 5000, 10000, 20000, 50000, 100000 punten). Het savebestand is 8 bytes groot, één byte per achievement:
// Save: 8 bytes (0x00 = vergrendeld, 0x01 = vrijgespeeld)
int achievements_save(const Achievements *a, const char *path)
{
FILE *f = fopen(path, "wb");
if (!f) return -1;
for (int i = 0; i < ACH_COUNT; i++) {
uint8_t flag = (uint8_t)a->list[i].unlocked;
fwrite(&flag, 1, 1, f);
}
fclose(f);
return 0;
}
// Laden met standaardwaarden als het bestand niet bestaat
int achievements_load(Achievements *a, const char *path)
{
achievements_init(a); // standaard allemaal vergrendeld
FILE *f = fopen(path, "rb");
if (!f) return -1; // eerste start: nog geen bestand
for (int i = 0; i < ACH_COUNT; i++) {
uint8_t flag = 0;
if (fread(&flag, 1, 1, f) != 1) break;
a->list[i].unlocked = flag ? 1 : 0;
}
fclose(f);
return 0;
}De gebruikersinstellingen opslaan
De instellingen van 2048-3DS (taal, muziekvolume, effectvolume, mutestand) passen in 4 bytes:
// Formaat: [0] = taal, [1] = muziekvolume, [2] = SFX-volume, [3] = mute
static void settings_save(int music_volume, int sfx_volume, int music_muted)
{
FILE *f = fopen(SETTINGS_SAVE_PATH, "wb");
if (!f) return;
u8 data[4];
data[0] = (u8)lang_current; // enum Language (0..12)
data[1] = (u8)music_volume; // 0..128
data[2] = (u8)sfx_volume; // 0..128
data[3] = (u8)(music_muted ? 1 : 0);
fwrite(data, 1, 4, f);
fclose(f);
}Voordelen van die aanpak voor een homebrewgame: vaste grootte, geen parsing, geen afhankelijkheid van een JSON- of XML-bibliotheek, direct inlezen. De instellingen worden automatisch opgeslagen zodra de speler het instellingenmenu of de applicatie verlaat.
10. De 3DS-Makefile: ARM-cross-compilatie met devkitARM
Het buildsysteem voor 3DS-homebrew leunt op de regels die devkitARM meelevert. De 3DS-Makefile begrijpen is essentieel om compilatieproblemen te debuggen.
Opbouw van de Makefile voor een 3DS-homebrewproject
# Controleren of devkitARM is ingesteld
ifeq ($(strip $(DEVKITARM)),)
$(error "Please set DEVKITARM in your environment")
endif
include $(DEVKITARM)/3ds_rules
# Configuratie van het homebrewproject
TARGET := 2048
BUILD := build_3ds
SOURCES := source
ROMFS := romfs
# Metadata voor de Homebrew Launcher en het HOME-menu
APP_TITLE := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR := GekkodeARM-compileerflags voor de Nintendo 3DS
ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft
CFLAGS := -g -Wall -Wextra -O2 -mword-relocations \
-ffunction-sections $(ARCH) -std=c99
CFLAGS += $(INCLUDE) -D__3DS__Wat elke flag van de ARM-cross-compilatie voor de 3DS doet:
-march=armv6k: de architectuur van de ARM11-processor in de Nintendo 3DS-mtune=mpcore: optimaliseert de code voor de MPCore-mfloat-abi=hard: gebruikt de VFP (floating point in hardware), cruciaal voor de grafische prestaties-mword-relocations: genereert de relocaties die het 3DSX-formaat nodig heeft-ffunction-sections: laat de linker dode code verwijderen en de binary kleiner maken-D__3DS__: definieert de macro voor de#ifdef-blokken tussen pc en 3DS
De bibliotheken citro2d en libctru linken
LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS := -lcitro2d -lcitro3d -lctru -lmDe volgorde van de bibliotheken telt voor de GNU-linker: -lcitro2d hangt af van -lcitro3d, dat weer afhangt van -lctru. Orden ze altijd van de hoogste naar de laagste abstractie.
Bronbestanden filteren: de pc-rendering uit de 3DS-build houden
# main_sdl.c uitsluiten van de 3DS-build (alleen main_3ds.c wordt gecompileerd)
CFILES := $(filter-out main_sdl.c, \
$(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))))Dankzij dat filter wordt alleen main_3ds.c meegecompileerd in de 3DS-build.
Buildcommando’s
# De homebrew compileren naar .3dsx
make -f Makefile.3ds
# Compileren en een installeerbare .cia bouwen
make -f Makefile.3ds cia
# Volledig opschonen
make -f Makefile.3ds clean11. Een .3dsx-bestand bouwen voor de Homebrew Launcher
Het formaat .3dsx is het standaard homebrewformaat van de Nintendo 3DS, dat je start via de Homebrew Launcher. Het is ook het eenvoudigst om te verspreiden: één bestand kopiëren naar de SD-kaart.
De 3DS-Makefile genereert automatisch twee bestanden:
2048.3dsx: de homebrew-executable, inclusief het ingebakken RomFS2048.smdh: de SMDH-metadata (titel, auteur, beschrijving, icoon)
SMDH: de metadata van je 3DS-homebrew
Het SMDH-bestand (Simple Metadata Header) bevat de informatie die de Homebrew Launcher toont:
# Gedefinieerd in Makefile.3ds
APP_TITLE := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR := GekkodeHet icoon is een PNG van 48×48 pixels, dat automatisch wordt gevonden als icon.png of 2048.png in de root van het project staat.
Het RomFS in de .3dsx meebakken
Het RomFS zit rechtstreeks in het .3dsx-bestand. De eindgebruiker hoeft maar één bestand te beheren:
_3DSXFLAGS += --romfs=$(CURDIR)/romfs12. Een installeerbaar .cia-bestand bouwen voor het HOME-menu van de 3DS
Het formaat .cia (CTR Importable Archive) is het installeerbare formaat van de Nintendo 3DS. Zodra je het via FBI of een andere titelmanager installeert, verschijnt de game in het HOME-menu van de console met een eigen icoon, een geanimeerde banner en een opstartgeluid.
Het is ook het formaat waarin ik 2048 voor Nintendo 3DS als .cia verspreid.
Let op: dit is veruit de lastigste stap van 3DS-homebrewontwikkeling. Ik heb eindeloos zitten uitzoeken waarom een .cia-bestand perfect draaide in de emulator Citra en meteen crashte op een echte 3DS. De oorzaak is bijna altijd een verkeerd geconfigureerd RSF-bestand. Wat hierna volgt komt uit vele uren testen op echte hardware, ik loop elke parameter langs zodat jij dat niet hoeft.
Assets die je nodig hebt voor een .cia
- Icoon: PNG van exact 48×48 pixels (te zien in het HOME-menu)
- Bannerafbeelding: PNG van exact 256×128 pixels (bovenaan het scherm zodra de game geselecteerd is)
- Bannergeluid: WAV in PCM 16-bit, 44100 Hz, stereo, ongeveer 3 seconden (klinkt zodra de game in het HOME-menu geselecteerd wordt). Het formaat is streng, converteer met
ffmpeg:
# Elk audiobestand omzetten naar een WAV die bannertool aankan
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wavZo bouw je een CIA-bestand voor de 3DS
Een .cia bouwen gebeurt in 4 stappen. De aanbevolen aanpak is een RSF-template met variabelen $(VARIABLE) die makerom vervangt via de flags -DVARIABLE="valeur". Zo houd je de configuratie (in de Makefile) gescheiden van het permissietemplate (in het RSF), en blijft de build schoon en herbruikbaar:
# 1. De ELF-binary bouwen
make -f Makefile.3ds
# 2. De banner genereren (afbeelding + opstartgeluid)
bannertool makebanner \
-i assets/banner.png \
-a assets/music/banner.wav \
-o build_3ds/banner.bnr
# 3. Het SMDH-icoon voor het HOME-menu genereren
bannertool makesmdh \
-s "2048" \
-l "2048 - puzzle game for 3DS" \
-p "Gekkode" \
-i icon.png \
-o build_3ds/icon.icn
# 4. De uiteindelijke .cia samenstellen met makerom
makerom -f cia -o 2048.cia \
-elf 2048.elf \
-rsf app.rsf \
-target t \
-exefslogo \
-icon build_3ds/icon.icn \
-banner build_3ds/banner.bnr \
-major 1 -minor 0 -micro 0 \
-DAPP_TITLE="2048" \
-DAPP_PRODUCT_CODE="CTR-H-2048" \
-DAPP_UNIQUE_ID="0xF2048" \
-DAPP_ENCRYPTED=false \
-DAPP_SYSTEM_MODE="64MB" \
-DAPP_SYSTEM_MODE_EXT="Legacy" \
-DAPP_CATEGORY="Application" \
-DAPP_USE_ON_SD="true" \
-DAPP_MEMORY_TYPE="Application" \
-DAPP_CPU_SPEED="268MHz" \
-DAPP_ENABLE_L2_CACHE="false" \
-DAPP_VERSION_MAJOR="1" \
-DAPP_ROMFS="romfs"Elke flag -DXXX="valeur" vervangt de bijbehorende variabele $(XXX) in het RSF-bestand. Dankzij die substitutie heb je één herbruikbaar RSF-template voor al je projecten, alleen de -D-flags verschillen per project.
De belangrijkste makerom-flags:
-target t: doel “test” (voor homebrew die niet door Nintendo is gesigneerd)-exefslogo: neemt het logo op in de ExeFS (nodig voor het splashscreen bij het opstarten)-major / -minor / -micro: de versie van de titel (zichtbaar in de systeeminstellingen)
Het RSF-bestand: de permissies van je CIA-homebrew instellen
Het RSF-bestand (ROM Specification File) is het meest kritieke onderdeel van de CIA-build. Het legt de metadata, de systeempermissies, de toegestane services en de beschikbare system calls vast. Een onvolledig RSF = meteen een crash op echte hardware, ook al werkt alles in Citra.
Dit is het volledige RSF-template van 2048-3DS, uitgewerkt na veel tests op echte hardware. Daarna licht ik elke sectie toe:
BasicInfo: de identiteit van de titel
BasicInfo:
Title : $(APP_TITLE)
ProductCode : $(APP_PRODUCT_CODE)
Logo : NintendoTitle: de naam die in de systeeminstellingen verschijnt. Gebruikt de variabele$(APP_TITLE), vervangen door-DAPP_TITLE="2048".ProductCode: een identifier in het formaatCTR-H-XXXX. DeHstaat voor homebrew. Kies een unieke code (bijv.CTR-H-2048).Logo: het geanimeerde splashscreen dat bij het opstarten verschijnt.Nintendo: het officiële geanimeerde 3DS-logo (aanbevolen, dit is het standaardgedrag)Homebrew: het logo van de homebrewcommunityLicensed/Distributed: varianten van het Nintendo-logoNone: vermijden, kan op sommige firmwareversies crashes veroorzaken
RomFs: ingebakken resources
RomFs:
RootPath: $(APP_ROMFS)Het pad naar de map romfs/ met de ingebakken assets (sprites, fonts, audio). Doorgegeven via -DAPP_ROMFS="romfs". Heeft je applicatie geen RomFS, dan mag je deze sectie weglaten.
TitleInfo: unieke identificatie
TitleInfo:
Category : $(APP_CATEGORY)
UniqueId : $(APP_UNIQUE_ID)Category: altijdApplicationvoor een homebrewgame of -tool.UniqueId: een unieke hexadecimale identifier in het bereik0xF0000tot0xFFFFF(gereserveerd voor homebrew). Elke homebrew op de console moet een eigen UniqueId hebben om conflicten te voorkomen. Voor 2048 gebruik ik0xF2048.
Option: packagingopties
Option:
UseOnSD : $(APP_USE_ON_SD)
FreeProductCode : true
MediaFootPadding : false
EnableCrypt : $(APP_ENCRYPTED)
EnableCompress : trueUseOnSD:true(verplicht voor homebrew die op de SD-kaart wordt geïnstalleerd).FreeProductCode:truezodat makerom een vrije ProductCode accepteert (zonder controle op het Nintendo-formaat).EnableCrypt:false(homebrew heeft geen encryptiesleutels van Nintendo).EnableCompress:trueom de .code-sectie van de ExeFS te comprimeren (kleiner .cia-bestand).
AccessControlInfo: de kritieke sectie
Hier wordt 90% van de CIA-crashes beslist. Deze sectie bepaalt alles wat je applicatie op de console mag doen. Ontbreekt er één system call of service, dan crasht de CIA meteen op echte hardware, terwijl Citra die beperkingen negeert.
AccessControlInfo:
CoreVersion : 2
DescVersion : 2
ReleaseKernelMajor : "02"
ReleaseKernelMinor : "33"
UseExtSaveData : false
MemoryType : $(APP_MEMORY_TYPE)
SystemMode : $(APP_SYSTEM_MODE)
SystemModeExt : $(APP_SYSTEM_MODE_EXT)
CpuSpeed : $(APP_CPU_SPEED)
EnableL2Cache : $(APP_ENABLE_L2_CACHE)
IdealProcessor : 0
AffinityMask : 1
Priority : 16
MaxCpu : 0x9E
HandleTableSize : 0x200
DisableDebug : false
EnableForceDebug : false
CanWriteSharedPage : true
CanUsePrivilegedPriority : false
CanUseNonAlphabetAndNumber : true
PermitMainFunctionArgument : true
CanShareDeviceMemory : true
RunnableOnSleep : false
SpecialMemoryArrange : true
CanAccessCore2 : trueDe parameters die je per project aanpast:
MemoryType:Applicationvoor een gewone game of tool.Systemvoor een systeemmodule.SystemMode: hoeveel RAM wordt toegewezen.64MBis de standaard. Gebruik96MBalleen voor New 3DS-exclusieve applicaties die meer geheugen nodig hebben.SystemModeExt:Legacyvoor compatibiliteit met Old 3DS én New 3DS.CpuSpeed:268MHzzet de klokboost aan op de New 3DS (op de Old 3DS valt hij automatisch terug op 268 MHz).EnableL2Cache: standaardfalse. Zet optruevoor CPU-intensieve applicaties op de New 3DS (kan de Old 3DS instabiel maken).
Daarna volgen de vier subsecties met permissies. Mijn advies: neem de volledige set over. Een eenvoudige homebrew gebruikt niet al die SVC’s en services, maar er te veel declareren kost op een console met CFW niets aan prestaties of veiligheid, er één vergeten levert wel meteen een crash op:
FileSystemAccess: toegang tot het bestandssysteem
FileSystemAccess:
- CategorySystemApplication
- CategoryHardwareCheck
- CategoryFileSystemTool
- Debug
- TwlCardBackup
- TwlNandData
- Boss
- DirectSdmc
- Core
- CtrNandRo
- CtrNandRw
- CtrNandRoWrite
- CategorySystemSettings
- CardBoard
- ExportImportIvs
- DirectSdmcWrite
- SwitchCleanup
- SaveDataMove
- Shop
- Shell
- CategoryHomeMenu
- SeedDBHet belangrijkst voor een eenvoudige homebrew: DirectSdmc + DirectSdmcWrite (lezen en schrijven op SD) en Core. Maar de volledige set overnemen is veiliger.
IoAccessControl: low-level I/O-toegang
IoAccessControl:
- FsMountNand
- FsMountNandRoWrite
- FsMountTwln
- FsMountWnand
- FsMountCardSpi
- UseSdif3
- CreateSeed
- UseCardSpiSystemCallAccess: ARM11-system calls (SVC’s)
Deze lijst bepaalt welke system calls (supervisor calls) je applicatie mag gebruiken. Dit is de meest kritieke sectie: ontbreekt een SVC die libctru aanroept, dan crasht de console meteen. Neem alle SVC’s van 1 tot 125 op:
SystemCallAccess:
ControlMemory : 1
QueryMemory : 2
ExitProcess : 3
GetProcessAffinityMask : 4
SetProcessAffinityMask : 5
GetProcessIdealProcessor : 6
SetProcessIdealProcessor : 7
CreateThread : 8
ExitThread : 9
SleepThread : 10
GetThreadPriority : 11
SetThreadPriority : 12
GetThreadAffinityMask : 13
SetThreadAffinityMask : 14
GetThreadIdealProcessor : 15
SetThreadIdealProcessor : 16
GetCurrentProcessorNumber : 17
Run : 18
CreateMutex : 19
ReleaseMutex : 20
CreateSemaphore : 21
ReleaseSemaphore : 22
CreateEvent : 23
SignalEvent : 24
ClearEvent : 25
CreateTimer : 26
SetTimer : 27
CancelTimer : 28
ClearTimer : 29
CreateMemoryBlock : 30
MapMemoryBlock : 31
UnmapMemoryBlock : 32
CreateAddressArbiter : 33
ArbitrateAddress : 34
CloseHandle : 35
WaitSynchronization1 : 36
WaitSynchronizationN : 37
SignalAndWait : 38
DuplicateHandle : 39
GetSystemTick : 40
GetHandleInfo : 41
GetSystemInfo : 42
GetProcessInfo : 43
GetThreadInfo : 44
ConnectToPort : 45
SendSyncRequest1 : 46
SendSyncRequest2 : 47
SendSyncRequest3 : 48
SendSyncRequest4 : 49
SendSyncRequest : 50
OpenProcess : 51
OpenThread : 52
GetProcessId : 53
GetProcessIdOfThread : 54
GetThreadId : 55
GetResourceLimit : 56
GetResourceLimitLimitValues : 57
GetResourceLimitCurrentValues : 58
GetThreadContext : 59
Break : 60
OutputDebugString : 61
ControlPerformanceCounter : 62
CreatePort : 71
CreateSessionToPort : 72
CreateSession : 73
AcceptSession : 74
ReplyAndReceive1 : 75
ReplyAndReceive2 : 76
ReplyAndReceive3 : 77
ReplyAndReceive4 : 78
ReplyAndReceive : 79
BindInterrupt : 80
UnbindInterrupt : 81
InvalidateProcessDataCache : 82
StoreProcessDataCache : 83
FlushProcessDataCache : 84
StartInterProcessDma : 85
StopDma : 86
GetDmaState : 87
RestartDma : 88
DebugActiveProcess : 96
BreakDebugProcess : 97
TerminateDebugProcess : 98
GetProcessDebugEvent : 99
ContinueDebugEvent : 100
GetProcessList : 101
GetThreadList : 102
GetDebugThreadContext : 103
SetDebugThreadContext : 104
QueryDebugProcessMemory : 105
ReadProcessMemory : 106
WriteProcessMemory : 107
SetHardwareBreakPoint : 108
GetDebugThreadParam : 109
ControlProcessMemory : 112
MapProcessMemory : 113
UnmapProcessMemory : 114
CreateCodeSet : 115
CreateProcess : 117
TerminateProcess : 118
SetProcessResourceLimits : 119
CreateResourceLimit : 120
SetResourceLimitValues : 121
AddCodeSegment : 122
Backdoor : 123
KernelSetState : 124
QueryProcessMemory : 125Belangrijk: de nummers lopen niet aaneengesloten door, er zitten gaten in (63-70, 89-95, 110-111, 116). Dat is normaal: het gaat om SVC’s die de 3DS-kernel reserveert of niet implementeert.
ServiceAccessControl: systeemservices
Services zijn de high-level API’s van de 3DS. Elke libctru-bibliotheek gebruikt er een of meer. Staat een service hier niet, dan mislukt de aanroep van srvGetServiceHandle() en crasht de init van de bijbehorende bibliotheek:
ServiceAccessControl:
- APT:U # Levenscyclus van de applicatie (aptMainLoop)
- ac:u # Netwerkconfiguratie
- am:net # Application Manager (titels installeren)
- boss:U # SpotPass
- cam:u # Camera
- cecd:u # StreetPass
- cfg:nor # NOR-configuratie
- cfg:u # Systeemconfiguratie (taal, regio)
- csnd:SND # CSND-audio
- dsp::DSP # NDSP-audio (belangrijkste backend)
- frd:u # Vriendenlijst
- fs:USER # Bestandssysteem (SDMC, RomFS)
- gsp::Gpu # PICA200-GPU (graphics)
- gsp::Lcd # Aansturing van het LCD-scherm
- hid:USER # Input (knoppen, pad, touch)
- http:C # HTTP-client
- ir:rst # Infrarood (C-stick New 3DS)
- ir:u # Infrarood, generiek
- ir:USER # Infrarood, gebruiker
- mic:u # Microfoon
- mcu::HWC # Hardwaremicrocontroller
- ndm:u # Network Daemon Manager
- news:s # Notificaties
- nwm::EXT # Uitgebreide Network Manager
- nwm::UDS # Network Manager UDS (local wireless)
- ptm:sysm # Power/Timer Manager, systeem
- ptm:u # Power/Timer Manager, gebruiker
- pxi:dev # PXI device access
- soc:U # Sockets (TCP/UDP-netwerk)
- ssl:C # SSL/TLS
- y2r:u # Conversie van YUV naar RGBVoor een eenvoudige homebrewgame zijn onmisbaar: APT:U, fs:USER, gsp::Gpu en hid:USER. Voeg dsp::DSP toe als je audio gebruikt en cfg:u als je de systeemconfiguratie uitleest. Maar net als bij de SVC’s is de volledige set veiliger, een ongebruikte service declareren kost je niets.
SystemControlInfo: stack, savedata en dependencies
SystemControlInfo:
SaveDataSize: 0KB
RemasterVersion: $(APP_VERSION_MAJOR)
StackSize: 0x40000
Dependency:
ac: 0x0004013000002402
am: 0x0004013000001502
boss: 0x0004013000003402
camera: 0x0004013000001602
cecd: 0x0004013000002602
cfg: 0x0004013000001702
codec: 0x0004013000001802
csnd: 0x0004013000002702
dlp: 0x0004013000002802
dsp: 0x0004013000001a02
friends: 0x0004013000003202
gpio: 0x0004013000001b02
gsp: 0x0004013000001c02
hid: 0x0004013000001d02
http: 0x0004013000002902
i2c: 0x0004013000001e02
ir: 0x0004013000003302
mcu: 0x0004013000001f02
mic: 0x0004013000002002
ndm: 0x0004013000002b02
news: 0x0004013000003502
nim: 0x0004013000002c02
nwm: 0x0004013000002d02
pdn: 0x0004013000002102
ps: 0x0004013000003102
ptm: 0x0004013000002202
ro: 0x0004013000003702
socket: 0x0004013000002e02
spi: 0x0004013000002302
ssl: 0x0004013000002f02SaveDataSize:0KBals je rechtstreeks op de SD-kaart opslaat (wat de meeste homebrew doet). Vul een grootte in als je de save-API van het systeem gebruikt.StackSize:0x40000(256 KB) is een prima standaard. Verhoog die als je app zwaar recursief werkt of grote arrays op de stack zet.Dependency: de title-ID’s van de systeemmodules waarvan je applicatie afhangt. Let op: title-ID’s mogen GEEN achtervoegselLhebben.0x0004013000002402Lschrijven in plaats van0x0004013000002402kan stille fouten opleveren. Neem de lijst hierboven letterlijk over.
IORegisterMapping en MemoryMapping
IORegisterMapping:
- 1ff00000-1ff7ffff
MemoryMapping:
- 1f000000-1f5fffff:rDeze secties leggen vast welke geheugenbereiken en I/O-registers bereikbaar zijn. Neem de waarden hierboven ongewijzigd over, ze dekken de behoeften van elke standaard homebrew.
Het RSF aanpassen aan je eigen project
Het RSF-template hierboven is generiek en herbruikbaar. Om het naar een nieuw project te tillen, verander je alleen de -D-flags in het commando makerom:
Flag -D | Waarde in 2048 | Aan te passen |
|---|---|---|
APP_TITLE | 2048 | Naam van je app |
APP_PRODUCT_CODE | CTR-H-2048 | unieke CTR-H-XXXX |
APP_UNIQUE_ID | 0xF2048 | Unieke hexwaarde in 0xF0000-0xFFFFF |
APP_ROMFS | romfs | Pad naar je romfs-map |
APP_SYSTEM_MODE | 64MB | 96MB bij New 3DS-exclusief |
APP_VERSION_MAJOR | 1 | Majorversienummer |
De overige waarden (APP_ENCRYPTED=false, APP_CATEGORY=Application, APP_USE_ON_SD=true enzovoort) zijn voor alle homebrew hetzelfde.
De CIA-build automatiseren in de Makefile
# Variabelen bovenaan Makefile.3ds
APP_TITLE := 2048
APP_PRODUCT_CODE := CTR-H-2048
APP_UNIQUE_ID := 0xF2048
BANNER_IMAGE := $(TOPDIR)/assets/banner.png
BANNER_AUDIO := $(TOPDIR)/assets/music/banner.wav
RSF_FILE := $(TOPDIR)/app.rsf
# CIA-target — één enkele "make -f Makefile.3ds cia"
cia: all
@bannertool makebanner -i $(BANNER_IMAGE) -a $(BANNER_AUDIO) \
-o $(BUILD)/banner.bnr
@bannertool makesmdh -s "$(APP_TITLE)" -l "$(APP_TITLE) - $(APP_DESCRIPTION)" \
-p "$(APP_AUTHOR)" -i $(APP_ICON) -o $(BUILD)/icon.icn
@makerom -f cia -o $(TARGET).cia \
-elf $(TARGET).elf \
-rsf $(RSF_FILE) \
-target t \
-exefslogo \
-icon $(BUILD)/icon.icn \
-banner $(BUILD)/banner.bnr \
-major 1 -minor 0 -micro 0 \
-DAPP_TITLE="$(APP_TITLE)" \
-DAPP_PRODUCT_CODE="$(APP_PRODUCT_CODE)" \
-DAPP_UNIQUE_ID="$(APP_UNIQUE_ID)" \
-DAPP_ENCRYPTED=false \
-DAPP_SYSTEM_MODE="64MB" \
-DAPP_SYSTEM_MODE_EXT="Legacy" \
-DAPP_CATEGORY="Application" \
-DAPP_USE_ON_SD="true" \
-DAPP_MEMORY_TYPE="Application" \
-DAPP_CPU_SPEED="268MHz" \
-DAPP_ENABLE_L2_CACHE="false" \
-DAPP_VERSION_MAJOR="1" \
-DAPP_ROMFS="$(ROMFS)"De extensies .bnr en .icn: de bestanden die
bannertoolgenereert hebben de extensies.bnr(banner) en.icn(icon). Sommige tutorials gebruiken.bin, maar de juiste extensies zijn.bnren.icn, dat doen de gangbare buildtools ook.
13. Valkuilen en veelgemaakte fouten bij 3DS-homebrewontwikkeling
Ik ben tijdens de ontwikkeling van 2048-3DS tegen al deze muren aangelopen, en ik had deze lijst graag vanaf het begin gehad. Dit zijn de meest frustrerende fouten en hoe je ze voorkomt, dat scheelt je flink wat uren debuggen bij je eerste homebrewgame voor de Nintendo 3DS.
VRAM-artefacten: de spookbug op het 3DS-scherm
Symptoom: er verschijnen willekeurige gekleurde pixels op het scherm, elke frame anders.
Oorzaak: C2D_TargetClear() vergeten vóór C2D_SceneBegin(). Het VRAM van de 3DS wordt niet automatisch gewist, er staan resten van de vorige frame of willekeurige data in.
// FOUT — gegarandeerde VRAM-artefacten
C2D_SceneBegin(top);
render_game();
// GOED — altijd wissen voordat je tekent
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_game();linearAlloc versus malloc: de geheugenvalkuil bij 3DS-audio
Symptoom: geen geluid, of alleen ruis en gekraak.
Oorzaak: audiobuffers op de 3DS moeten met linearAlloc() worden gealloceerd. Geheugen van malloc() is niet bereikbaar voor de DSP-processor. Vrijgeven doe je met linearFree() (niet met free()).
// FOUT — de DSP kan dit geheugen niet lezen
u8 *audio_buf = malloc(size);
// GOED — lineair geheugen, bereikbaar voor de DSP
u8 *audio_buf = linearAlloc(size);
// ... gebruik ...
linearFree(audio_buf);DSP_FlushDataCache vergeten: corrupte audio op de 3DS
Symptoom: het geluid is corrupt, loopt uit de pas of klopt helemaal niet.
Oorzaak: de CPU-cache en het DSP-geheugen van de 3DS zijn niet coherent. Je moet de cache expliciet flushen nadat je in een audiobuffer hebt geschreven:
fread(snd->data, 1, chunk_size, f);
DSP_FlushDataCache(snd->data, snd->size); // VERPLICHT!Verkeerd audioformaat: volledige stilte
Symptoom: geen enkel geluid, of een crash tijdens het laden.
Oorzaak: het WAV-bestand is gecomprimeerd (ADPCM, ingekapselde MP3 enzovoort) in plaats van ruwe PCM. De 3DS ondersteunt alleen ongecomprimeerde PCM. Controleren en converteren:
# De codec van het WAV-bestand controleren
ffprobe music.wav 2>&1 | grep "Audio:"
# Moet tonen: pcm_s16le (16 bit) of pcm_u8 (8 bit)
# Een MP3 omzetten naar 3DS-compatibele WAV PCM
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 22050 music.wavC2D_Color32 is geen constante expressie in C99
Symptoom: de compileerfout “initializer element is not constant” met citro2d in C99.
Oorzaak: C2D_Color32() is in C een inline functie, geen constante expressie. Je kunt er in C99 dus geen globale variabelen mee initialiseren. Oplossing:
// FOUT in C99
static u32 my_color = C2D_Color32(0xFF, 0x00, 0x00, 0xFF);
// GOED — macro MAKE_COLOR in plaats van C2D_Color32
#define MAKE_COLOR(r,g,b,a) \
((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))
static u32 my_color = MAKE_COLOR(0xFF, 0x00, 0x00, 0xFF);De .3dsx werkt maar de .cia crasht: RSF-permissies
Symptoom: de homebrew draait prima als .3dsx via de Homebrew Launcher, maar crasht meteen als .cia (vaak met de melding “ErrDisp: An error has occurred” of “SD Card was removed”). In Citra werkt de .cia wel.
Oorzaak: het .3dsx-formaat erft de ruime permissies van de Homebrew Launcher. Het .cia-formaat heeft eigen permissies, vastgelegd in het RSF-bestand. Citra negeert die beperkingen, en daarom draait de .cia wel in de emulator en niet op hardware. Drie hoofdoorzaken:
- Onvolledige SystemCallAccess: is een SVC die libctru gebruikt niet gedeclareerd, dan weigert de ARM11-kernel de aanroep en wordt het proces gekild. Neem alle SVC’s van 1 tot 125 op.
- Ontbrekende ServiceAccessControl: is een service (bijv.
dsp::DSPofgsp::Gpu) niet gedeclareerd, dan misluktsrvGetServiceHandle()en crasht de init van de bijbehorende bibliotheek. Neem alle services uit sectie 12 over. - Logo: None in het RSF: op sommige firmwares veroorzaakt een ontbrekend logo in de ExeFS een crash bij het opstarten. Gebruik
Logo: Nintendosamen met de flag-exefslogo.
Oplossing: gebruik het volledige RSF-template uit sectie 12, met de complete set SVC’s, services en permissies. Dat is de betrouwbaarste manier om een .cia te krijgen die op echte hardware werkt.
ndspWaveBuf niet gereset: het geluid speelt maar één keer
Symptoom: een geluidseffect speelt de eerste keer prima af en daarna nooit meer.
Oorzaak: na het afspelen blijft de status van de ndspWaveBuf op “done” staan. Je moet de status resetten en de cache opnieuw flushen voordat je hem weer afspeelt:
snd->wave_buf.status = NDSP_WBUF_FREE; // resetten
DSP_FlushDataCache(snd->data, snd->size); // opnieuw flushen
ndspChnWaveBufAdd(channel, &snd->wave_buf); // opnieuw afspelenromfsInit() vergeten: geen enkele asset laadt
Symptoom: elke fopen("romfs:/...") geeft NULL terug. Fonts, textures en geluiden laden niet.
Oorzaak: romfsInit() is niet aan het begin van het programma aangeroepen. Die aanroep is verplicht vóór elke toegang tot het RomFS-bestandssysteem.
Verkeerde afmetingen voor de CIA-bannerassets
De afmetingen zijn strikt voor het CIA-formaat:
- Icoon: PNG van exact 48×48 pixels
- Banner: PNG van exact 256×128 pixels
- Banneraudio: WAV PCM, bij voorkeur kort (~3 seconden)
Kloppen de afmetingen niet, dan faalt bannertool stilletjes of levert het een corrupte binary op waardoor de installatie crasht.
14. Conclusie: je eigen homebrewgame voor de Nintendo 3DS maken
Een homebrewgame ontwikkelen voor de Nintendo 3DS raakt aan een hoop boeiende hoeken van het programmeervak: embedded systeemprogrammeren, 2D-graphics met citro2d, low-level audio met NDSP, specifiek geheugenbeheer en ARM-cross-compilatie. De beperkingen van het platform (weinig geheugen, twee schermen, eigen formaten) dwingen je om schone, snelle code te schrijven, en eerlijk: ik heb me kostelijk vermaakt met die uitdagingen.
De 6 gouden regels van 3DS-homebrewontwikkeling
- Scheid je spellogica van de rendering. De portable logica in een eigen bestand, de 3DS-rendering in een ander. Dankzij die aanpak kwam 2048-3DS vlot van de grond.
- C2D_TargetClear vóór elke scène. Altijd. Zonder uitzondering. Anders krijg je VRAM-artefacten.
- linearAlloc voor audio, nooit malloc. En vergeet
DSP_FlushDataCache()nooit. - Uitsluitend WAV PCM voor al je audiobestanden. Converteer je MP3’s en OGG’s voordat je ze meebakt.
- Test naast de .3dsx ook de .cia. De permissies verschillen, en één vergeten service in het RSF levert een crash op.
- Declareer alle systeemservices in het RSF-bestand, in het bijzonder
dsp::DSP,csnd:SND,hid:USERenfs:USER.
Het project 2048 voor Nintendo 3DS brengt elk concept uit deze tutorial in praktijk: rendering op twee schermen, vloeiende animaties, audio over meerdere tracks, binaire saves, lokalisatie in 13 talen, achievements en packaging als .3dsx en .cia. Ik heb het open source gezet, zodat het als basis kan dienen voor je eigen projecten.
Download 2048 voor Nintendo 3DS, gratis beschikbaar als .3dsx en .cia. Neem de broncode erbij om al die concepten in actie te zien. En begin je zelf aan een 3DS-homebrewproject: deel het gerust, de community is gastvrij en helpt je graag verder.
Ter vergelijking met een aanpak zonder zelf code te schrijven: ons artikel Game maken met ChatGPT volgt Codex, de programmeer-AI van OpenAI, terwijl hij een kleine retro-RPG in de geest van EarthBound bouwt, speelbaar in de browser.
Bronnen voor 3DS-homebrewontwikkeling
- devkitPro:
https://devkitpro.org/, officiële toolchain voor 3DS-homebrewontwikkeling - libctru-documentatie:
https://libctru.devkitpro.org/, volledige referentie van de libctru-API - citro2d-headers: de headerbestanden in
$DEVKITARM/../libctru/include/citro2d/vormen de beste documentatie van de grafische bibliotheek - 3dbrew Wiki:
https://www.3dbrew.org/, uitputtende technische wiki over de Nintendo 3DS (hardware, formaten, systeemservices) - Officiële voorbeelden:
https://github.com/devkitPro/3ds-examples, voorbeelden voor graphics, audio en netwerk - bannertool:
https://github.com/Steveice10/bannertool, generator van banners en iconen voor het CIA-formaat - makerom:
https://github.com/3DSGuy/Project_CTR, assembler van CIA/CCI-bestanden voor de Nintendo 3DS
Technische specificaties van de Nintendo 3DS
- CPU: ARM11 MPCore (ARMv6K) @ 268 MHz, 2 cores (4 op de New 3DS)
- GPU: DMP PICA200 @ 268 MHz
- RAM: 128 MB (256 MB op de New 3DS), waarvan 6 MB toegewijd VRAM
- Bovenste scherm: 400×240 pixels (800×240 in stereoscopische 3D-modus)
- Onderste scherm: 320×240 pixels, resistief touchscreen
- Audio: 24 DSP-kanalen, stereo-uitgang


