Nintendo 3DS homebrew ontwikkelen: je eigen game in C

Nintendo 3DS homebrew ontwikkelen: je eigen game in C

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

  1. Wat is een 3DS-homebrew
  2. Vereisten: devkitARM en de 3DS-ontwikkeltools installeren
  3. Opbouw van een homebrewproject: logica en rendering scheiden
  4. De twee schermen van de Nintendo 3DS aansturen
  5. 2D-graphics renderen met citro2d
  6. Input: D-pad, circle pad en touchscreen
  7. Audio in 3DS-homebrew: NDSP en WAV PCM
  8. Bestandssysteem: RomFS en SD-kaart
  9. Data opslaan op de SD-kaart
  10. De 3DS-Makefile: ARM-cross-compilatie met devkitARM
  11. Een .3dsx-bestand bouwen voor de Homebrew Launcher
  12. Een installeerbaar .cia-bestand bouwen voor het HOME-menu
  13. Valkuilen en veelgemaakte fouten bij 3DS-homebrew
  14. 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:

bash
# 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/devkitARM

Essentië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

treeview
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:

c
// 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 bereikt

De 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

c
// 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:

c
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

c
#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 scherm

Let 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

c
// 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:

c
// 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 tekst

Afgeronde 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:

c
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:

c
// 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:

code
# 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)
c
// 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:

c
// 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

c
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

c
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:

c
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

c
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.

c
// 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

c
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:

c
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

c
// 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:

c
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

c
// 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:

c
// 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:

bash
# In Makefile.3ds
ROMFS := romfs

Assetformaten 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:/:

c
// 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:

c
// 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:

c
// 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

bash
# 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      := Gekkode

ARM-compileerflags voor de Nintendo 3DS

makefile
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

makefile
LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS    := -lcitro2d -lcitro3d -lctru -lm

De 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

bash
# 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

bash
# 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 clean

11. 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 RomFS
  • 2048.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:

bash
# Gedefinieerd in Makefile.3ds
APP_TITLE       := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR      := Gekkode

Het 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:

c
_3DSXFLAGS += --romfs=$(CURDIR)/romfs

12. 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:
bash
# Elk audiobestand omzetten naar een WAV die bannertool aankan
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wav

Zo 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:

bash
# 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

yaml
BasicInfo:
  Title                   : $(APP_TITLE)
  ProductCode             : $(APP_PRODUCT_CODE)
  Logo                    : Nintendo
  • Title: de naam die in de systeeminstellingen verschijnt. Gebruikt de variabele $(APP_TITLE), vervangen door -DAPP_TITLE="2048".
  • ProductCode: een identifier in het formaat CTR-H-XXXX. De H staat 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 homebrewcommunity
    • Licensed / Distributed: varianten van het Nintendo-logo
    • None: vermijden, kan op sommige firmwareversies crashes veroorzaken

RomFs: ingebakken resources

c
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

yaml
TitleInfo:
  Category                : $(APP_CATEGORY)
  UniqueId                : $(APP_UNIQUE_ID)
  • Category: altijd Application voor een homebrewgame of -tool.
  • UniqueId: een unieke hexadecimale identifier in het bereik 0xF0000 tot 0xFFFFF (gereserveerd voor homebrew). Elke homebrew op de console moet een eigen UniqueId hebben om conflicten te voorkomen. Voor 2048 gebruik ik 0xF2048.

Option: packagingopties

c
Option:
  UseOnSD                 : $(APP_USE_ON_SD)
  FreeProductCode         : true
  MediaFootPadding        : false
  EnableCrypt             : $(APP_ENCRYPTED)
  EnableCompress          : true
  • UseOnSD: true (verplicht voor homebrew die op de SD-kaart wordt geïnstalleerd).
  • FreeProductCode: true zodat makerom een vrije ProductCode accepteert (zonder controle op het Nintendo-formaat).
  • EnableCrypt: false (homebrew heeft geen encryptiesleutels van Nintendo).
  • EnableCompress: true om 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.

yaml
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                : true

De parameters die je per project aanpast:

  • MemoryType: Application voor een gewone game of tool. System voor een systeemmodule.
  • SystemMode: hoeveel RAM wordt toegewezen. 64MB is de standaard. Gebruik 96MB alleen voor New 3DS-exclusieve applicaties die meer geheugen nodig hebben.
  • SystemModeExt: Legacy voor compatibiliteit met Old 3DS én New 3DS.
  • CpuSpeed: 268MHz zet de klokboost aan op de New 3DS (op de Old 3DS valt hij automatisch terug op 268 MHz).
  • EnableL2Cache: standaard false. Zet op true voor 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

yaml
  FileSystemAccess:
   - CategorySystemApplication
   - CategoryHardwareCheck
   - CategoryFileSystemTool
   - Debug
   - TwlCardBackup
   - TwlNandData
   - Boss
   - DirectSdmc
   - Core
   - CtrNandRo
   - CtrNandRw
   - CtrNandRoWrite
   - CategorySystemSettings
   - CardBoard
   - ExportImportIvs
   - DirectSdmcWrite
   - SwitchCleanup
   - SaveDataMove
   - Shop
   - Shell
   - CategoryHomeMenu
   - SeedDB

Het 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

yaml
  IoAccessControl:
   - FsMountNand
   - FsMountNandRoWrite
   - FsMountTwln
   - FsMountWnand
   - FsMountCardSpi
   - UseSdif3
   - CreateSeed
   - UseCardSpi

SystemCallAccess: 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:

yaml
  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            : 125

Belangrijk: 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:

yaml
  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 RGB

Voor 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

yaml
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: 0x0004013000002f02
  • SaveDataSize: 0KB als 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 achtervoegsel L hebben. 0x0004013000002402L schrijven in plaats van 0x0004013000002402 kan stille fouten opleveren. Neem de lijst hierboven letterlijk over.

IORegisterMapping en MemoryMapping

yaml
  IORegisterMapping:
   - 1ff00000-1ff7ffff
  MemoryMapping:
   - 1f000000-1f5fffff:r

Deze 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 -DWaarde in 2048Aan te passen
APP_TITLE2048Naam van je app
APP_PRODUCT_CODECTR-H-2048unieke CTR-H-XXXX
APP_UNIQUE_ID0xF2048Unieke hexwaarde in 0xF0000-0xFFFFF
APP_ROMFSromfsPad naar je romfs-map
APP_SYSTEM_MODE64MB96MB bij New 3DS-exclusief
APP_VERSION_MAJOR1Majorversienummer

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

bash
# 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 bannertool genereert hebben de extensies .bnr (banner) en .icn (icon). Sommige tutorials gebruiken .bin, maar de juiste extensies zijn .bnr en .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.

c
// 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()).

c
// 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:

c
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:

bash
# 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.wav

C2D_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:

c
// 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::DSP of gsp::Gpu) niet gedeclareerd, dan mislukt srvGetServiceHandle() 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: Nintendo samen 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:

c
snd->wave_buf.status = NDSP_WBUF_FREE;    // resetten
DSP_FlushDataCache(snd->data, snd->size);  // opnieuw flushen
ndspChnWaveBufAdd(channel, &snd->wave_buf); // opnieuw afspelen

romfsInit() 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

  1. 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.
  2. C2D_TargetClear vóór elke scène. Altijd. Zonder uitzondering. Anders krijg je VRAM-artefacten.
  3. linearAlloc voor audio, nooit malloc. En vergeet DSP_FlushDataCache() nooit.
  4. Uitsluitend WAV PCM voor al je audiobestanden. Converteer je MP3’s en OGG’s voordat je ze meebakt.
  5. Test naast de .3dsx ook de .cia. De permissies verschillen, en één vergeten service in het RSF levert een crash op.
  6. Declareer alle systeemservices in het RSF-bestand, in het bijzonder dsp::DSP, csnd:SND, hid:USER en fs: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
Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.