
Du willst dein eigenes Homebrew-Spiel für die Nintendo 3DS bauen? Gute Nachricht: Das geht nach wie vor, und es macht richtig Spaß. In diesem Tutorial teile ich alles, was ich beim Entwickeln von 2048 für Nintendo 3DS gelernt habe, ein komplettes Homebrew-Spiel mit Animationen, Audio, 13 Sprachen und Achievements, von der ersten Zeile Code bis zur fertigen installierbaren .cia-Datei.
Das fertige Ergebnis ansehen: 2048 für Nintendo 3DS, kostenlos als .3dsx und .cia zum Download.
Dieser Leitfaden deckt die komplette Pipeline der 3DS-Homebrew-Entwicklung ab: 2D-Grafik mit citro2d, Audio über NDSP, Touchscreen, das Dateisystem RomFS und das Packaging als CIA. Der gesamte Code ist C99. Keine graue Theorie, nur Code, der wirklich läuft, direkt aus dem Projekt 2048-3DS.
Inhalt
- Was ist ein 3DS-Homebrew
- Voraussetzungen: devkitARM und die 3DS-Werkzeuge installieren
- Aufbau eines Homebrew-Projekts: Spiellogik und Rendering trennen
- Die beiden Bildschirme der Nintendo 3DS ansteuern
- 2D-Grafik mit citro2d
- Eingaben verarbeiten: D-Pad, Circle Pad und Touchscreen
- 3DS-Homebrew-Audio: NDSP und das Format WAV PCM
- Dateisystem: RomFS und SD-Karte
- Daten auf der SD-Karte speichern
- Das 3DS-Makefile: ARM-Cross-Compilation mit devkitARM
- Eine .3dsx-Datei für den Homebrew Launcher erzeugen
- Eine installierbare .cia-Datei für das HOME-Menü bauen
- Typische Fallstricke und Fehler bei der 3DS-Homebrew-Entwicklung
- Fazit und Ressourcen zum Weiterlesen
1. Was ist ein Nintendo-3DS-Homebrew?
Ein 3DS-Homebrew ist eine inoffizielle Anwendung, die auf einer Nintendo 3DS mit Custom Firmware (CFW) wie Luma3DS läuft. Die 3DS-Homebrew-Szene erlaubt es, Spiele, Emulatoren und Werkzeuge für die Handheld-Konsole frei zu entwickeln und zu verteilen.
Die Nintendo 3DS hat einen ARM11-Prozessor (ARMv6K) mit 268 MHz und eine PICA200-GPU, dazu eine Besonderheit, die sie einzigartig macht: zwei Bildschirme. Der obere zeigt 400×240 Pixel (800×240 bei stereoskopischem 3D), der untere, berührungsempfindliche zeigt 320×240 Pixel.
Für 3DS-Homebrews gibt es zwei Verteilungsformate:
- .3dsx: das Homebrew-Format, das über den Homebrew Launcher gestartet wird. Keine Installation, direkte Ausführung von der SD-Karte.
- .cia: das installierbare Format, das im HOME-Menü der 3DS auftaucht, mit Icon und animiertem Banner. Setzt eine Custom Firmware (CFW) voraus.
Für 2048-3DS baue ich beide Formate, um möglichst viele Spieler zu erreichen. Die 3DS-Homebrew-Community lebt dank eines soliden Werkzeug-Ökosystems weiter: devkitARM (Toolchain), libctru (Systembibliothek), citro2d/citro3d (Grafik). Wer einen guten Spielplatz sucht, um hardwarenahe Systemprogrammierung oder Retro-Spieleentwicklung zu lernen, ist bei der 3DS genau richtig.
2. Voraussetzungen: devkitARM und die 3DS-Werkzeuge installieren
Bevor du anfängst, ein Spiel für die Nintendo 3DS zu programmieren, brauchst du die Toolchain für die ARM-Cross-Compilation und die konsolenspezifischen Bibliotheken.
Toolchain devkitARM: der Compiler für die Nintendo 3DS
devkitARM ist die GCC-Cross-Toolchain für ARM-Prozessoren, bereitgestellt vom Projekt devkitPro. Sie enthält Compiler, Linker und die Build-Werkzeuge, die du brauchst, um C-Code für die 3DS zu kompilieren. So installierst du sie:
# Installation unter Linux/WSL (empfohlen für die 3DS-Entwicklung)
wget https://apt.devkitpro.org/install-devkitpro-pacman
chmod +x install-devkitpro-pacman
sudo ./install-devkitpro-pacman
# 3DS-Entwicklungspakete installieren
sudo dkp-pacman -S 3ds-dev
# Umgebungsvariable ist Pflicht
export DEVKITARM=/opt/devkitpro/devkitARMUnverzichtbare Bibliotheken für die 3DS-Homebrew-Entwicklung
- libctru: hardwarenahe C-Bibliothek für die Systemdienste der 3DS (HID, Dateisystem, Audio, GPU). Sie ist die Grundlage jedes 3DS-Homebrew-Programms.
- citro3d: Abstraktion über der PICA200-GPU. Kümmert sich um Render-Targets, Framebuffer und Grafiksynchronisation.
- citro2d: 2D-Schicht über citro3d. Liefert einfache Primitive (Rechtecke, Kreise, Ellipsen, Linien, Text, Sprites) und ist damit ideal für 2D-Spiele auf der 3DS.
Zusätzliche Werkzeuge für das Packaging
- bannertool: erzeugt die Dateien banner.bin und icon.bin, die das CIA-Format braucht
- makerom: setzt die fertige .cia-Datei aus ELF, RSF, Banner und Icon zusammen
- tex3ds: wandelt PNG-Bilder in .t3x-Texturen um, die citro2d auf der PICA200-GPU lesen kann
- mkbcfnt: wandelt TTF-Schriften in das .bcfnt-Format für die Textausgabe auf der 3DS um (unterstützt Unicode, CJK, Kyrillisch)
3. Aufbau eines 3DS-Homebrew-Projekts: Spiellogik und Rendering trennen
Der klassische Anfängerfehler in der Homebrew-Entwicklung ist, den gesamten Code direkt gegen die Konsolen-APIs zu schreiben. Das Ergebnis: Testen und Debuggen wird unmöglich. Ich bin da durch, und mein Rat an alle lautet: Trenne die Spiellogik vom Rendering, und zwar von Anfang an.
Genau diese Strategie steckt hinter 2048-3DS: Die Logik des Spiels (Raster, Bewegungen, Fusionen, Punktestand) ist zu 100 % portabel, das Rendering dagegen plattformspezifisch.
Dateistruktur des Projekts 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)Portabler Code: die 2048-Spiellogik ohne Abhängigkeiten
Die Datei logic.c von 2048-3DS bindet keinen einzigen Header der Nintendo 3DS ein. Sie nutzt ausschließlich Standard-C-Header (<stdint.h>, <stdlib.h>, <string.h>, <time.h>). Die Datei main_3ds.c ruft die öffentlichen Funktionen der Logik auf:
// logic.h — portable Schnittstelle des Spiels 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; // Position vor der Bewegung
int to_r, to_c; // Position nach der Bewegung
uint16_t value; // während der Animation angezeigter Wert
int merged; // 1 bei Fusion
} TileAnim;
typedef struct {
uint16_t cells[GRID_SIZE][GRID_SIZE]; // 4x4-Raster
uint32_t score;
int won, over;
TileAnim anims[MAX_TILE_ANIMS]; // Animationsdaten des letzten Zugs
int anim_count;
int spawn_r, spawn_c; // Position der neuen Kachel
uint16_t spawn_val; // Wert (2 zu 90 %, 4 zu 10 %)
} Game;
void game_init(Game *g); // leeres Raster + 2 Kacheln
int game_move(Game *g, Direction dir); // gibt 1 zurück, wenn sich das Raster geändert hat
int game_is_over(Game *g); // keine Bewegung mehr möglich
int game_has_won(Game *g); // Kachel >= 2048 erreichtDas 3DS-Makefile kompiliert logic.c + achievements.c + main_3ds.c und filtert main_sdl.c explizit heraus. Die Spiellogik bleibt gleich, egal welches Render-Frontend darüberliegt.
4. Die beiden Bildschirme der Nintendo 3DS ansteuern
Was die Nintendo 3DS von jedem anderen Handheld unterscheidet, sind ihre zwei Bildschirme. Sie sinnvoll zu nutzen, ist der Schlüssel zu einem guten Homebrew-Erlebnis.
- Oberer Bildschirm (top): 400×240 Pixel, nicht berührungsempfindlich. Ideal für das eigentliche Gameplay. In 2048-3DS zeige ich hier das 4×4-Raster mit den Kachelanimationen.
- Unterer Bildschirm (bottom): 320×240 Pixel, berührungsempfindlich. Ideal für Oberfläche, Menüs und Steuerung. In 2048-3DS liegen dort Punktestand, Buttons, Einstellungen und Achievements.
Render-Targets mit citro2d initialisieren
// Initialisierung des 3DS-Grafiksystems
gfxInitDefault();
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
C2D_Prepare();
// Für jeden Bildschirm ein Render-Target anlegen
C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);Renderschleife für zwei Bildschirme
In jedem Frame musst du auf beide Bildschirme getrennt zeichnen. So ist die Renderschleife von 2048-3DS aufgebaut:
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);
// Oberer Bildschirm — Raster des Spiels 2048
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_top_screen(&game, anim_phase, progress);
// Unterer Bildschirm — UI (Punkte, Buttons, Einstellungen)
C2D_TargetClear(bot, col_bot_bg);
C2D_SceneBegin(bot);
render_bot_game(&game, best_score, 0, music_muted);
C3D_FrameEnd(0);Kritischer Fallstrick beim 3DS-Rendering: C2D_TargetClear ist Pflicht. Ohne
C2D_TargetClear()vor jeder Szene bekommst du zufällige Bildfehler, VRAM-Reste des vorherigen Frames. Der VRAM der 3DS wird nicht automatisch initialisiert. Dieser Bug ist für Homebrew-Einsteiger extrem verwirrend, weil die Artefakte in jedem Frame anders aussehen.
Bildschirmmaße: die wichtigsten Konstanten
#define TOP_W 400 // Breite oberer Bildschirm
#define TOP_H 240 // Höhe oberer Bildschirm
#define BOT_W 320 // Breite unterer Bildschirm (Touch)
#define BOT_H 240 // Höhe unterer BildschirmAchtung: Auf der echten Konsole sitzt der untere Bildschirm horizontal zentriert unter dem oberen (400 gegenüber 320 Pixel Breite). Die Touch-Koordinaten entsprechen direkt den Pixeln des unteren Bildschirms, mit (0,0) oben links.
5. 2D-Grafik mit citro2d auf der Nintendo 3DS
citro2d ist die Standardbibliothek für 2D-Rendering in 3DS-Homebrew-Spielen. Sie setzt auf citro3d und der PICA200-GPU auf und übernimmt intern das Batching der Primitive sowie die Verwaltung der GPU-Texturen. Hier ist der praktische Einsatz, mit konkreten Beispielen aus 2048-3DS.
Primitive zeichnen: Rechtecke, Kreise, Ellipsen und Linien
// Gefülltes Rechteck (Kacheln des Spiels 2048)
C2D_DrawRectSolid(x, y, z, largeur, hauteur, couleur);
// Gefüllter Kreis (abgerundete Ecken, Icons)
C2D_DrawCircleSolid(centre_x, centre_y, z, rayon, couleur);
// Gefüllte Ellipse (Musik-Icon in 2048-3DS)
C2D_DrawEllipseSolid(x, y, z, largeur, hauteur, couleur);
// Linie (Balken für die Stummschaltung)
C2D_DrawLine(x1, y1, couleur1, x2, y2, couleur2, epaisseur, z);Der Parameter z steuert die Tiefe (0.0f für die Standardebene). Farben liegen auf der 3DS im Speicher im Format ABGR vor, Vorsicht, das ist die Umkehrung des klassischen ARGB. citro2d liefert das Makro C2D_Color32(r, g, b, a), um Farben korrekt zusammenzubauen:
// C99-Falle: C2D_Color32() ist nicht constexpr
// Für globale Farbkonstanten ein Makro verwenden
#define MAKE_COLOR(r,g,b,a) \
((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))
// Farbpalette des Spiels 2048 auf der 3DS
#define col_grid_bg MAKE_COLOR(0xBB, 0xAD, 0xA0, 0xFF) // Hintergrund Raster
#define col_bot_bg MAKE_COLOR(0xFA, 0xF8, 0xEF, 0xFF) // Hintergrund unterer Bildschirm
#define col_text_dk MAKE_COLOR(0x77, 0x6E, 0x65, 0xFF) // dunkler Text
#define col_text_lt MAKE_COLOR(0xF9, 0xF6, 0xF2, 0xFF) // heller TextAbgerundete Rechtecke: ein Grafiktrick für citro2d
citro2d kennt keine Primitive für ein „abgerundetes Rechteck“. Für die Kacheln des 2048-Spiels und die Buttons der Oberfläche habe ich diese Technik gebaut: ein mittleres Rechteck, zwei seitliche Rechtecke und vier Kreise an den Ecken:
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;
}
// Mittlerer Korpus + seitliche Ränder
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 Kreise mit Radius r an den Ecken
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);
}Diese Funktion kommt in 2048-3DS überall zum Einsatz: Spielkacheln, Punkteboxen, Buttons, Lautstärkeregler, Bestätigungsdialoge.
Text auf der Nintendo 3DS ausgeben: das Format .bcfnt
Die Textausgabe auf der 3DS läuft über das Format .bcfnt (Binary CTR Font). Jede Zeichenkette muss erst in einen Textbuffer „geparst“ werden, bevor sie gezeichnet werden kann. In 2048-3DS nutze ich das für Punktestände, Button-Beschriftungen in 13 Sprachen und Achievement-Benachrichtigungen:
// Initialisierung der Textbuffer
C2D_TextBuf s_dynamicBuf = C2D_TextBufNew(512);
C2D_Font s_font = C2D_FontLoad("romfs:/font.bcfnt");
// Hilfsfunktion: zentrierten Text zeichnen
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);
}
// Wichtig: Textbuffer in jedem Frame leeren
C2D_TextBufClear(s_dynamicBuf);Der Faktor 0.62f ist so gewählt, dass der Text optisch sauber vertikal zentriert sitzt.
Sprites und GPU-Texturen im Format .t3x
Bilder müssen mit dem Werkzeug tex3ds ins Format .t3x umgewandelt werden (3DS-Textur, optimiert für die PICA200-GPU). Eine .t3s-Datei beschreibt das Sprite Sheet. In 2048-3DS stecken die 8 Achievement-Icons (je 32×32 Pixel) in einem einzigen 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)// Sprite Sheet aus RomFS laden
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");
// Ein Achievement-Icon zeichnen
C2D_Image img = C2D_SpriteSheetGetImage(sheet, index);
C2D_DrawImageAt(img, x, y, 0.0f, NULL, scale_x, scale_y);
// Darstellung in Graustufen (Achievement gesperrt)
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);Flüssige Animationen auf der Nintendo 3DS
Für flüssige Animationen in einem 3DS-Homebrew-Spiel nimmst du osGetTime() (Zeit in Millisekunden) und interpolierst mit Easing. Das ist das Animationssystem, das in 2048-3DS für das Gleiten und Erscheinen der Kacheln sorgt:
// Timing der Animationen im Spiel 2048
#define ANIM_SLIDE_MS 120 // Dauer des Kachelgleitens
#define ANIM_POP_MS 100 // Dauer des „Pop“ (Fusion/Erscheinen)
typedef enum { ANIM_NONE, ANIM_SLIDING, ANIM_POPPING } AnimPhase;
// Quadratisches Easing: natürliches Abbremsen
static float ease_out_quad(float t) {
return t * (2.0f - t);
}
// In der Hauptschleife: Positionen interpolieren
u64 now = osGetTime();
u64 elapsed = now - anim_start;
float progress = (float)elapsed / ANIM_SLIDE_MS;
float t = ease_out_quad(progress);
// Lineares Gleiten der Kachel
float cur_x = from_px + (to_px - from_px) * t;
float cur_y = from_py + (to_py - from_py) * t;Die Animation läuft in zwei Phasen: zuerst das Gleiten (120 ms), dann der „Pop“ fusionierte Kacheln wachsen auf 125 % an und gehen wieder auf 100 % zurück, während die neue Kachel von 0 auf 100 % aufgeht. Dieses System macht das 2048-Spiel auf der 3DS visuell genauso befriedigend wie das Original.
6. Eingaben auf der Nintendo 3DS: D-Pad, Circle Pad und Touchscreen
Die Nintendo 3DS bietet Homebrew-Spielen mehrere Eingabegeräte, alle über die Bibliothek libctru (HID) erreichbar. In 2048-3DS nutze ich alle drei: D-Pad und Circle Pad zum Verschieben der Kacheln, den Touchscreen für Buttons und Regler.
Physische Tasten lesen: D-Pad und die Tasten A/B/X/Y
hidScanInput(); // Eingabestatus einlesen (1x pro Frame)
u32 kDown = hidKeysDown(); // In diesem Frame neu gedrückte Tasten
// D-Pad: Kacheln im Spiel 2048 verschieben
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; }
// Systemtasten
if (kDown & KEY_B) { /* Zurück-Navigation */ }
if (kDown & KEY_START) { break; /* Anwendung beenden */ }
if (kDown & KEY_SELECT) { game_init(&game); /* neues Spiel */ }Circle Pad: der Analogstick der 3DS mit Deadzone
circlePosition cpad;
hidCircleRead(&cpad);
// Deadzone von 80 gegen Fehlauslösungen
// Bereich des Circle Pad: etwa -155 bis +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; }Die Deadzone ist beim Circle Pad der 3DS entscheidend. Ohne Schwellwert meldet der Stick permanent Mikrobewegungen und löst ungewollte Züge aus. Ein Wert von 80 (bei einem Bereich von etwa -155 bis +155) funktioniert für ein Spiel wie 2048 in der Praxis gut.
Touchscreen: Klicks auf die Buttons der Oberfläche auswerten
Der Touchscreen der 3DS ist resistiv (druckbasiert, nicht kapazitiv). Die Koordinaten liegen direkt in Pixeln des unteren Bildschirms (320×240). In 2048-3DS bedient der Touchscreen die Buttons „Neues Spiel“, „Achievements“ und „Einstellungen“, die Lautstärkeregler und die Sprachauswahl:
if (kDown & KEY_TOUCH) {
touchPosition touch;
hidTouchRead(&touch);
int mx = touch.px; // 0..319
int my = touch.py; // 0..239
// Klick auf den Button „Neues Spiel“ erkennen
float btn_x = (BOT_W - BTN_W) / 2; // horizontal zentriert
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;
}
}Nimm hidKeysDown() mit KEY_TOUCH für einzelne Tipper (Buttons) und hidKeysHeld() für fortlaufende Interaktionen (Lautstärkeregler, Ziehen).
Hauptschleife eines 3DS-Homebrew-Spiels
while (aptMainLoop()) {
u64 now = osGetTime();
audio_music_tick(music_volume, music_muted);
hidScanInput();
u32 kDown = hidKeysDown();
if (kDown & KEY_START) break; // Sauberes Beenden
// Eingaben verarbeiten (D-Pad, Circle Pad, Touch)
// Spiellogik von 2048 aktualisieren
// Beide Bildschirme rendern
// ...
}aptMainLoop() kümmert sich um den Lebenszyklus der Homebrew-Anwendung: Ruhezustand, Beenden durch das System, Rückkehr ins HOME-Menü.
7. 3DS-Homebrew-Audio: NDSP und das Format WAV PCM
Glaub mir aus Erfahrung: Audio auf der Nintendo 3DS ist der Bereich, in dem ich die meiste Zeit verloren habe. Auf dem Papier sieht alles einfach aus, in der Praxis lauern überall Fallen. Das Audio-Backend der 3DS heißt NDSP (Nintendo DSP): Es braucht die DSP-Firmware (dspfirm.bin auf der SD-Karte) und beherrscht Mehrkanal-Mixing mit linearer Interpolation.
In 2048-3DS habe ich Hintergrundmusik auf 4 Tracks sowie Soundeffekte für Kachelbewegungen und Achievements umgesetzt.
Audioformat für die 3DS: WAV PCM ist Pflicht
Der größte Fallstrick beim 3DS-Audio: Die Konsole unterstützt ausschließlich rohes WAV PCM (kein MP3, kein OGG, kein ADPCM-komprimiertes WAV). Die Dateien müssen 8 oder 16 Bit PCM sein, mono oder stereo. Jedes andere Format wird stillschweigend ignoriert oder führt zum Absturz.
Speicher für 3DS-Audio: linearAlloc statt malloc
Audiobuffer auf der 3DS müssen mit linearAlloc() belegt werden, nicht mit malloc(). Auf linearen Speicher greift der DSP-Prozessor direkt zu, auf den normalen Heap nicht.
// Struktur zum Speichern einer WAV-Audiodatei auf der 3DS
typedef struct {
u8 *data; // PCM-Daten (mit linearAlloc belegt)
u32 size; // Größe in Bytes
u32 sample_rate; // Abtastrate
u16 channels; // 1 (Mono) oder 2 (Stereo)
u16 bits_per_sample; // 8 oder 16 Bit
ndspWaveBuf wave_buf; // NDSP-Buffer
int loaded; // Flag: Laden erfolgreich
} WavSound;WAV-Dateien im 3DS-Homebrew laden
static int wav_load(WavSound *snd, const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) return -1;
// RIFF/WAVE-Header lesen und prüfen
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;
}
// WAV-Chunks durchlaufen (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, bits_per_sample auslesen
// ...
} else if (memcmp(chunk_id, "data", 4) == 0) {
// WICHTIG: linearAlloc, nicht malloc!
snd->data = (u8 *)linearAlloc(chunk_size);
fread(snd->data, 1, chunk_size, f);
} else {
fseek(f, chunk_size, SEEK_CUR);
}
}
// NDSP-Buffer vorbereiten
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;
// PFLICHT: CPU-Cache zum DSP flushen
DSP_FlushDataCache(snd->data, snd->size);
return 0;
}DSP_FlushDataCache ist Pflicht nach jedem Schreibzugriff auf einen Audiobuffer der 3DS. Ohne diesen Aufruf liest der DSP kaputte Daten, weil der CPU-Cache nicht mit dem DSP-Speicher kohärent ist. Das ist die Ursache Nummer 1 für stumme Audio-Bugs auf der Nintendo 3DS.
NDSP-Audio initialisieren
So sieht die Audio-Initialisierung in 2048-3DS aus:
static void audio_init(void)
{
if (ndspInit() != 0) return;
ndspSetOutputMode(NDSP_OUTPUT_STEREO);
// Kanal 0: Hintergrundmusik
// Kanal 1: SFX (Kachelbewegung)
// Kanal 2: SFX (Achievement freigeschaltet)
for (int ch = 0; ch < 3; ch++) {
ndspChnReset(ch);
ndspChnSetInterp(ch, NDSP_INTERP_LINEAR);
ndspChnSetFormat(ch, NDSP_FORMAT_STEREO_PCM16);
}
}Einen Sound abspielen und die Lautstärke steuern
// NDSP: Audio-Buffer zum Kanal hinzufügen
snd->wave_buf.status = NDSP_WBUF_FREE; // Status zurücksetzen
DSP_FlushDataCache(snd->data, snd->size);
ndspChnWaveBufAdd(channel, &snd->wave_buf);
// NDSP-Lautstärke: Array aus 12 Floats (Stereo-Mix pro Kanal)
float vol = (float)music_volume / 128.0f; // 0..128 -> 0.0..1.0
float mix[12] = {0};
mix[0] = vol; // linker Kanal
mix[1] = vol; // rechter Kanal
ndspChnSetMix(0, mix);Musiktitel automatisch aneinanderhängen
In 2048-3DS laufen 4 Musiktitel automatisch hintereinander (intro, music1, music2, music3). In jedem Frame prüfe ich, ob der aktuelle Titel zu Ende ist:
static void audio_music_tick(int music_volume, int music_muted)
{
if (!s_audio_init || music_muted) return;
if (!ndspChnIsPlaying(0)) {
// Zum nächsten Titel wechseln (zyklische Schleife)
s_music_current = (s_music_current + 1) % MUSIC_TRACK_COUNT;
audio_play_current_track(music_volume, music_muted);
}
}Audio am Programmende aufräumen
// Audio-Ressourcen sauber freigeben
ndspChnReset(0);
ndspChnReset(1);
ndspChnReset(2);
ndspExit();
// Linearen Speicher freigeben (nicht free, sondern 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. Das Dateisystem der 3DS: RomFS und SD-Karte
Die Nintendo 3DS stellt Homebrew-Anwendungen zwei Dateisysteme bereit: RomFS für Assets, die nur gelesen werden, und SDMC für Lesen und Schreiben auf der SD-Karte.
RomFS: Assets direkt ins Homebrew-Binary packen
Mit RomFS (Read-Only Memory FileSystem) packst du Dateien direkt in die .3dsx- oder .cia-Datei. Ideal für Assets, die sich nie ändern: Schriften, Texturen, Musik, Sprites. In 2048-3DS enthält der Ordner romfs/ die .bcfnt-Schrift mit CJK-Unterstützung, das Sprite Sheet der Achievements und 6 WAV-Dateien:
// Pflicht-Initialisierung vor jedem RomFS-Zugriff
romfsInit();
// Zugriff auf die Assets mit dem Präfix 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");Der Inhalt des Ordners romfs/ wird vom 3DS-Makefile automatisch eingebettet:
# In Makefile.3ds
ROMFS := romfsAsset-Formate speziell für die Nintendo 3DS
- .bcfnt: Bitmap-Schrift, kompiliert mit
mkbcfnt. Unterstützt vollständiges Unicode (CJK, Kyrillisch, Akzentzeichen). In 2048-3DS deckt eine einzige Schrift alle 13 Sprachen ab. - .t3x: GPU-Textur, kompiliert mit
tex3ds. Für die PICA200 optimiertes Format. Landet ohne Umwandlung direkt im VRAM. - .wav: rohes PCM-Audio. Keine Umwandlung zur Laufzeit, muss aber unkomprimiertes PCM sein (siehe Audio-Abschnitt).
SDMC: Daten auf der SD-Karte der 3DS speichern
Zum Speichern von Daten (Punktestände, Einstellungen, Fortschritt) schreiben 3DS-Homebrews mit dem Präfix sdmc:/ auf die SD-Karte:
// Speicherpfade für 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"
// Speicherverzeichnis beim Start anlegen
#include <sys/stat.h>
mkdir("sdmc:/3ds", 0777);
mkdir(SAVE_DIR, 0777);Geschrieben wird mit den Standard-C-Funktionen (fopen, fwrite, fclose). Für Dateioperationen auf SDMC brauchst du keine 3DS-spezifische API.
9. Daten im 3DS-Homebrew auf der SD-Karte speichern
Für ein 3DS-Homebrew-Spiel ist ein binärer Speicherstand der direkteste und sparsamste Weg. So löst 2048-3DS die Persistenz von Achievements und Einstellungen.
Binärer Speicherstand für die Achievements
Das Spiel 2048-3DS hat 8 Achievements (Punkteschwellen: 500, 1000, 2500, 5000, 10000, 20000, 50000, 100000 Punkte). Der Speicherstand belegt 8 Byte, ein Byte pro Achievement:
// Speicherstand: 8 Bytes (0x00 = gesperrt, 0x01 = freigeschaltet)
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 mit Standardwerten, falls die Datei fehlt
int achievements_load(Achievements *a, const char *path)
{
achievements_init(a); // standardmäßig alle gesperrt
FILE *f = fopen(path, "rb");
if (!f) return -1; // erster Start: keine Datei
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;
}Benutzereinstellungen speichern
Die Einstellungen von 2048-3DS (Sprache, Musiklautstärke, Effektlautstärke, Stummschaltung) landen in 4 Byte:
// Format: [0] = Sprache, [1] = Musiklautstärke, [2] = SFX-Lautstärke, [3] = Stumm
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);
}Die Vorteile für ein Homebrew-Spiel: feste Größe, kein Parsen, keine Abhängigkeit von einer JSON- oder XML-Bibliothek, sofortiges Lesen. Gespeichert wird automatisch, sobald der Nutzer das Einstellungsmenü oder die Anwendung verlässt.
10. Das 3DS-Makefile: ARM-Cross-Compilation mit devkitARM
Das Build-System für 3DS-Homebrews baut auf den Regeln von devkitARM auf. Wer das 3DS-Makefile versteht, debuggt Compile-Probleme deutlich schneller.
Aufbau des Makefiles für ein 3DS-Homebrew-Projekt
# Prüfen, ob devkitARM konfiguriert ist
ifeq ($(strip $(DEVKITARM)),)
$(error "Please set DEVKITARM in your environment")
endif
include $(DEVKITARM)/3ds_rules
# Konfiguration des Homebrew-Projekts
TARGET := 2048
BUILD := build_3ds
SOURCES := source
ROMFS := romfs
# Metadaten für den Homebrew Launcher und das HOME-Menü
APP_TITLE := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR := GekkodeCompiler-Flags für ARM auf der 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__Was die einzelnen Flags der ARM-Cross-Compilation für die 3DS bedeuten:
-march=armv6k: Architektur des ARM11-Prozessors der Nintendo 3DS-mtune=mpcore: optimiert den Code für den MPCore-mfloat-abi=hard: nutzt die VFP (Fließkomma in Hardware), entscheidend für die Grafikleistung-mword-relocations: erzeugt die Relokationen, die das 3DSX-Format braucht-ffunction-sections: erlaubt dem Linker, toten Code zu entfernen und das Binary zu verkleinern-D__3DS__: setzt das Makro für bedingte#ifdef-Blöcke zwischen PC und 3DS
citro2d und libctru linken
LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS := -lcitro2d -lcitro3d -lctru -lmDie Reihenfolge der Bibliotheken zählt für den GNU-Linker: -lcitro2d hängt von -lcitro3d ab, das wiederum von -lctru. Immer von der höchsten zur niedrigsten Abstraktionsebene sortieren.
Quelldateien filtern: das PC-Rendering aus dem 3DS-Build nehmen
# main_sdl.c vom 3DS-Build ausschließen (nur main_3ds.c wird kompiliert)
CFILES := $(filter-out main_sdl.c, \
$(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))))Dieser Filter stellt sicher, dass für den 3DS-Build nur main_3ds.c kompiliert wird.
Build-Befehle
# Das Homebrew als .3dsx kompilieren
make -f Makefile.3ds
# Kompilieren und eine installierbare .cia erzeugen
make -f Makefile.3ds cia
# Vollständiges Aufräumen
make -f Makefile.3ds clean11. Eine .3dsx-Datei für den Homebrew Launcher erzeugen
Das Format .3dsx ist das Standard-Homebrew-Format der Nintendo 3DS und wird über den Homebrew Launcher gestartet. Es ist am einfachsten zu verteilen: eine einzige Datei, die auf die SD-Karte kopiert wird.
Das 3DS-Makefile erzeugt automatisch zwei Dateien:
2048.3dsx: die ausführbare Homebrew-Datei, inklusive eingebettetem RomFS2048.smdh: die SMDH-Metadaten (Titel, Autor, Beschreibung, Icon)
SMDH: die Metadaten deines 3DS-Homebrews
Die SMDH-Datei (Simple Metadata Header) enthält die Informationen, die der Homebrew Launcher anzeigt:
# In Makefile.3ds definiert
APP_TITLE := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR := GekkodeDas Icon ist ein PNG mit 48×48 Pixeln und wird automatisch erkannt, wenn icon.png oder 2048.png im Projektstamm liegt.
Das RomFS in die .3dsx einbetten
Das RomFS steckt direkt in der .3dsx-Datei. Der Endnutzer hat damit nur eine einzige Datei zu handhaben:
_3DSXFLAGS += --romfs=$(CURDIR)/romfs12. Eine installierbare .cia-Datei für das HOME-Menü der 3DS bauen
Das Format .cia (CTR Importable Archive) ist das installierbare Format der Nintendo 3DS. Einmal über FBI oder einen anderen Title-Manager installiert, erscheint das Spiel im HOME-Menü der Konsole, mit eigenem Icon, animiertem Banner und Startsound.
In diesem Format verteile ich 2048 für Nintendo 3DS als .cia.
Achtung: Das ist der mit Abstand heikelste Schritt der 3DS-Homebrew-Entwicklung. Ich habe ewig gebraucht, um zu verstehen, warum eine .cia im Emulator Citra tadellos läuft und auf einer echten 3DS sofort abstürzt. Die Ursache ist fast immer eine falsch konfigurierte RSF-Datei. Was jetzt kommt, ist das Ergebnis vieler Teststunden auf echter Hardware, ich erkläre jeden Parameter, damit du dir das ersparst.
Assets, die eine .cia braucht
- Icon: PNG mit exakt 48×48 Pixeln (wird im HOME-Menü angezeigt)
- Bannerbild: PNG mit exakt 256×128 Pixeln (erscheint oben auf dem Bildschirm, wenn das Spiel ausgewählt ist)
- Bannersound: WAV, PCM 16 Bit, 44100 Hz, stereo, etwa 3 Sekunden lang (läuft, wenn das Spiel im HOME-Menü ausgewählt wird). Das Format ist strikt, konvertiere mit
ffmpeg:
# Beliebiges Audio in bannertool-taugliches WAV umwandeln
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wavSo entsteht eine CIA-Datei für die 3DS
Der Bau einer .cia läuft in 4 Schritten ab. Empfehlenswert ist ein RSF-Template mit Variablen $(VARIABLE), die makerom über die Flags -DVARIABLE="valeur" ersetzt. So trennst du die Konfiguration (im Makefile) vom Berechtigungs-Template (im RSF), und der Build bleibt sauber und wiederverwendbar:
# 1. ELF-Binary bauen
make -f Makefile.3ds
# 2. Banner erzeugen (Bild + Startsound)
bannertool makebanner \
-i assets/banner.png \
-a assets/music/banner.wav \
-o build_3ds/banner.bnr
# 3. SMDH-Icon für das HOME-Menü erzeugen
bannertool makesmdh \
-s "2048" \
-l "2048 - puzzle game for 3DS" \
-p "Gekkode" \
-i icon.png \
-o build_3ds/icon.icn
# 4. Fertige .cia mit makerom zusammenbauen
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"Jedes Flag -DXXX="valeur" ersetzt die passende Variable $(XXX) in der RSF-Datei. Dank dieser Substitution reicht ein einziges wiederverwendbares RSF-Template für all deine Projekte, von Projekt zu Projekt ändern sich nur die -D-Flags.
Die wichtigen makerom-Flags:
-target t: Ziel „test“ (für Homebrews, die Nintendo nicht signiert hat)-exefslogo: nimmt das Logo ins ExeFS auf (nötig für den Splashscreen beim Start)-major / -minor / -micro: Version des Titels (wird in den Systemeinstellungen angezeigt)
Die RSF-Datei: die Berechtigungen deines CIA-Homebrews festlegen
Die RSF-Datei (ROM Specification File) ist der kritischste Teil des CIA-Builds. Sie definiert Metadaten, Systemberechtigungen, erlaubte Dienste und verfügbare Systemaufrufe. Eine unvollständige RSF bedeutet sofortigen Absturz auf echter Hardware, auch wenn in Citra alles läuft.
Hier ist das vollständige RSF-Template von 2048-3DS, entstanden nach vielen Tests auf echter Hardware. Jeder Abschnitt wird danach erklärt:
BasicInfo: Identität des Titels
BasicInfo:
Title : $(APP_TITLE)
ProductCode : $(APP_PRODUCT_CODE)
Logo : NintendoTitle: der Name, der in den Systemeinstellungen erscheint. Nutzt die Variable$(APP_TITLE), ersetzt durch-DAPP_TITLE="2048".ProductCode: eine Kennung im FormatCTR-H-XXXX. DasHsteht für Homebrew. Wähle einen eindeutigen Code (z. B.CTR-H-2048).Logo: der animierte Splashscreen beim Start.Nintendo: das offizielle animierte 3DS-Logo (empfohlen, das ist das Standardverhalten)Homebrew: das Logo der Homebrew-CommunityLicensed/Distributed: Varianten des Nintendo-LogosNone: besser meiden, kann auf manchen Firmware-Versionen Abstürze auslösen
RomFs: eingebettete Ressourcen
RomFs:
RootPath: $(APP_ROMFS)Pfad zum Ordner romfs/ mit den eingebetteten Assets (Sprites, Schriften, Audio). Wird über -DAPP_ROMFS="romfs" übergeben. Hat deine Anwendung kein RomFS, kannst du diesen Abschnitt weglassen.
TitleInfo: eindeutige Kennung
TitleInfo:
Category : $(APP_CATEGORY)
UniqueId : $(APP_UNIQUE_ID)Category: für ein Homebrew-Spiel oder -Werkzeug immerApplication.UniqueId: eindeutige Hexadezimalkennung im Bereich0xF0000bis0xFFFFF(der für Homebrews reservierte Bereich). Jedes installierte Homebrew braucht eine andere UniqueId, sonst gibt es Konflikte. Für 2048 nehme ich0xF2048.
Option: Packaging-Optionen
Option:
UseOnSD : $(APP_USE_ON_SD)
FreeProductCode : true
MediaFootPadding : false
EnableCrypt : $(APP_ENCRYPTED)
EnableCompress : trueUseOnSD:true(Pflicht für ein Homebrew, das auf der SD-Karte installiert wird).FreeProductCode:true, damit makerom einen freien ProductCode akzeptiert (ohne Prüfung des Nintendo-Formats).EnableCrypt:false(Homebrews haben keine Nintendo-Schlüssel zum Verschlüsseln).EnableCompress:true, um den .code-Abschnitt des ExeFS zu komprimieren (verkleinert die .cia).
AccessControlInfo: der kritische Abschnitt
Hier entscheiden sich 90 % aller CIA-Abstürze. Dieser Abschnitt legt fest, was deine Anwendung auf der Konsole überhaupt darf. Fehlt auch nur ein Systemaufruf oder ein Dienst, stürzt die CIA auf echter Hardware sofort ab, während Citra diese Einschränkungen schlicht ignoriert.
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 : trueDie Parameter, die du an dein Projekt anpassen solltest:
MemoryType:Applicationfür ein normales Spiel oder Werkzeug.Systemfür ein Systemmodul.SystemMode: Menge an zugewiesenem RAM.64MBist der Standard.96MBnur für Anwendungen, die exklusiv auf der New 3DS laufen und mehr Speicher brauchen.SystemModeExt:Legacyfür Kompatibilität mit Old 3DS und New 3DS.CpuSpeed:268MHzaktiviert den Taktboost auf der New 3DS (auf der Old 3DS fällt der Wert automatisch auf 268 MHz zurück).EnableL2Cache: standardmäßigfalse. Auftruesetzen für CPU-intensive Anwendungen auf der New 3DS (kann auf der Old 3DS instabil werden).
Danach folgen die vier Unterabschnitte für Berechtigungen. Mein Rat: Nimm das komplette Set. Ein einfaches Homebrew nutzt nicht alle SVCs und Dienste, aber zu viele zu deklarieren kostet auf einer Konsole mit CFW weder Leistung noch Sicherheit, einen einzigen zu vergessen führt dagegen zum sofortigen Absturz:
FileSystemAccess: Zugriff auf das Dateisystem
FileSystemAccess:
- CategorySystemApplication
- CategoryHardwareCheck
- CategoryFileSystemTool
- Debug
- TwlCardBackup
- TwlNandData
- Boss
- DirectSdmc
- Core
- CtrNandRo
- CtrNandRw
- CtrNandRoWrite
- CategorySystemSettings
- CardBoard
- ExportImportIvs
- DirectSdmcWrite
- SwitchCleanup
- SaveDataMove
- Shop
- Shell
- CategoryHomeMenu
- SeedDBFür ein einfaches Homebrew zählen vor allem DirectSdmc + DirectSdmcWrite (Lesen und Schreiben auf der SD) sowie Core. Sicherer ist es trotzdem, das komplette Set aufzunehmen.
IoAccessControl: hardwarenaher I/O-Zugriff
IoAccessControl:
- FsMountNand
- FsMountNandRoWrite
- FsMountTwln
- FsMountWnand
- FsMountCardSpi
- UseSdif3
- CreateSeed
- UseCardSpiSystemCallAccess: ARM11-Systemaufrufe (SVCs)
Diese Liste legt fest, welche Systemaufrufe (Supervisor Calls) deine Anwendung nutzen darf. Das ist der kritischste Abschnitt: Fehlt ein SVC, den libctru aufruft, stürzt die Konsole sofort ab. Nimm alle SVCs von 1 bis 125 auf:
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 : 125Wichtig: Die Nummern sind nicht durchgehend, es gibt Lücken (63-70, 89-95, 110-111, 116). Das ist normal, diese SVCs sind reserviert oder vom 3DS-Kernel nicht implementiert.
ServiceAccessControl: Systemdienste
Die Dienste sind die High-Level-APIs der 3DS. Jede libctru-Bibliothek nutzt einen oder mehrere davon. Ist ein Dienst hier nicht deklariert, schlägt der Aufruf von srvGetServiceHandle() fehl und die Initialisierung der zugehörigen Bibliothek stürzt ab:
ServiceAccessControl:
- APT:U # Lebenszyklus der Anwendung (aptMainLoop)
- ac:u # Netzwerkkonfiguration
- am:net # Application Manager (Titel installieren)
- boss:U # SpotPass
- cam:u # Kamera
- cecd:u # StreetPass
- cfg:nor # NOR-Konfiguration
- cfg:u # Systemkonfiguration (Sprache, Region)
- csnd:SND # CSND-Audio
- dsp::DSP # NDSP-Audio (Haupt-Backend)
- frd:u # Freundesliste
- fs:USER # Dateisystem (SDMC, RomFS)
- gsp::Gpu # PICA200-GPU (Grafik)
- gsp::Lcd # Steuerung des LCD-Bildschirms
- hid:USER # Eingaben (Tasten, Pad, Touch)
- http:C # HTTP-Client
- ir:rst # Infrarot (C-Stick der New 3DS)
- ir:u # Infrarot allgemein
- ir:USER # Infrarot Benutzer
- mic:u # Mikrofon
- mcu::HWC # Hardware-Mikrocontroller
- ndm:u # Network Daemon Manager
- news:s # Benachrichtigungen
- nwm::EXT # Network Manager erweitert
- nwm::UDS # Network Manager UDS (lokales WLAN)
- ptm:sysm # Power/Timer Manager System
- ptm:u # Power/Timer Manager Benutzer
- pxi:dev # PXI-Gerätezugriff
- soc:U # Sockets (TCP/UDP-Netzwerk)
- ssl:C # SSL/TLS
- y2r:u # Umwandlung YUV nach RGBFür ein einfaches Homebrew-Spiel sind diese Dienste unverzichtbar: APT:U, fs:USER, gsp::Gpu, hid:USER. Nimm dsp::DSP dazu, wenn du Audio nutzt, und cfg:u, wenn du die Systemkonfiguration ausliest. Wie bei den SVCs ist das komplette Set aber die sicherere Wahl, ein ungenutzter Dienst kostet nichts.
SystemControlInfo: Stack, Speicherstand und Abhängigkeiten
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:0KB, wenn du direkt auf die SD schreibst (so machen es die meisten Homebrews). Setze eine Größe, wenn du die System-API zum Speichern nutzt.StackSize:0x40000(256 KB) ist ein solider Standardwert. Erhöhe ihn, wenn deine App tief rekursiv arbeitet oder große Arrays auf dem Stack anlegt.Dependency: die Title-IDs der Systemmodule, von denen deine Anwendung abhängt. Achtung: Die Title-IDs dürfen KEINL-Suffix haben.0x0004013000002402Lstatt0x0004013000002402zu schreiben, kann stille Fehler auslösen. Übernimm die Liste oben unverändert.
IORegisterMapping und MemoryMapping
IORegisterMapping:
- 1ff00000-1ff7ffff
MemoryMapping:
- 1f000000-1f5fffff:rDiese Abschnitte legen fest, welche Speicherbereiche und I/O-Register erreichbar sind. Übernimm die Werte oben ohne Änderung, sie decken den Bedarf jedes Standard-Homebrews ab.
Die RSF an dein Projekt anpassen
Das RSF-Template oben ist allgemein und wiederverwendbar. Für ein neues Projekt änderst du nur die -D-Flags im Aufruf von makerom:
Flag -D | Wert bei 2048 | Anzupassen |
|---|---|---|
APP_TITLE | 2048 | Name deiner App |
APP_PRODUCT_CODE | CTR-H-2048 | eindeutiger CTR-H-XXXX |
APP_UNIQUE_ID | 0xF2048 | eindeutiger Hexwert in 0xF0000-0xFFFFF |
APP_ROMFS | romfs | Pfad zu deinem romfs-Ordner |
APP_SYSTEM_MODE | 64MB | 96MB, wenn New-3DS-exklusiv |
APP_VERSION_MAJOR | 1 | Hauptversionsnummer |
Die übrigen Werte (APP_ENCRYPTED=false, APP_CATEGORY=Application, APP_USE_ON_SD=true und so weiter) sind für alle Homebrews gleich.
Den CIA-Build im Makefile automatisieren
# Variablen oben in 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-Ziel — ein einziges „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)"Endungen .bnr und .icn: Die von
bannertoolerzeugten Dateien tragen die Endungen.bnr(Banner) und.icn(Icon). Manche Tutorials nutzen.bin, korrekt sind aber.bnrund.icn, so machen es auch die etablierten Buildtools.
13. Typische Fallstricke und Fehler bei der 3DS-Homebrew-Entwicklung
Ich bin während der Entwicklung von 2048-3DS gegen jede einzelne dieser Wände gelaufen und hätte diese Liste gern von Anfang an gehabt. Hier kommen die frustrierendsten Fehler und wie du sie vermeidest, das spart dir beim ersten eigenen Homebrew-Spiel für die Nintendo 3DS etliche Stunden Debugging.
VRAM-Artefakte: der Geisterbug auf dem 3DS-Bildschirm
Symptom: Zufällige farbige Pixel erscheinen auf dem Bildschirm, in jedem Frame anders.
Ursache: C2D_TargetClear() vor C2D_SceneBegin() vergessen. Der VRAM der 3DS wird nicht automatisch initialisiert, er enthält Reste des vorherigen Frames oder beliebige Daten.
// FALSCH — VRAM-Artefakte garantiert
C2D_SceneBegin(top);
render_game();
// RICHTIG — immer vor dem Zeichnen löschen
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_game();linearAlloc statt malloc: die Speicherfalle beim 3DS-Audio
Symptom: Der Ton bleibt stumm oder es knackt und rauscht.
Ursache: Audiobuffer auf der 3DS müssen mit linearAlloc() belegt werden. Auf Speicher von malloc() kann der DSP-Prozessor nicht zugreifen. Freigegeben wird entsprechend mit linearFree(), nicht mit free().
// FALSCH — der DSP kann diesen Speicher nicht lesen
u8 *audio_buf = malloc(size);
// RICHTIG — linearer Speicher, für den DSP erreichbar
u8 *audio_buf = linearAlloc(size);
// ... Verwendung ...
linearFree(audio_buf);DSP_FlushDataCache vergessen: kaputtes Audio auf der 3DS
Symptom: Der Ton ist verzerrt, asynchron oder komplett falsch.
Ursache: CPU-Cache und DSP-Speicher der 3DS sind nicht kohärent. Nach jedem Schreibzugriff auf einen Audiobuffer musst du den Cache explizit flushen:
fread(snd->data, 1, chunk_size, f);
DSP_FlushDataCache(snd->data, snd->size); // PFLICHT!Falsches Audioformat: komplette Stille
Symptom: Kein Ton, oder Absturz beim Laden.
Ursache: Die WAV-Datei ist komprimiert (ADPCM, gekapseltes MP3 und so weiter) statt rohes PCM. Die 3DS versteht nur unkomprimiertes PCM. Prüfen und konvertieren:
# Codec der WAV-Datei prüfen
ffprobe music.wav 2>&1 | grep "Audio:"
# Muss ausgeben: pcm_s16le (16 Bit) oder pcm_u8 (8 Bit)
# Ein MP3 in 3DS-taugliches WAV PCM umwandeln
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 22050 music.wavC2D_Color32 ist in C99 nicht konstant
Symptom: Compilerfehler „initializer element is not constant“ mit citro2d unter C99.
Ursache: C2D_Color32() ist in C eine Inline-Funktion, kein konstanter Ausdruck. Damit lassen sich globale Variablen in C99 nicht initialisieren. Die Lösung:
// FALSCH in C99
static u32 my_color = C2D_Color32(0xFF, 0x00, 0x00, 0xFF);
// RICHTIG — Makro MAKE_COLOR statt 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);Die .3dsx läuft, die .cia stürzt ab: RSF-Berechtigungen
Symptom: Das Homebrew läuft als .3dsx über den Homebrew Launcher einwandfrei, stürzt als .cia aber sofort ab (oft mit der Meldung „ErrDisp: An error has occurred“ oder „SD Card was removed“). In Citra funktioniert dieselbe .cia.
Ursache: Das Format .3dsx erbt die weiten Berechtigungen des Homebrew Launchers. Die .cia hat eigene Berechtigungen, festgelegt in der RSF-Datei. Der Emulator Citra ignoriert diese Einschränkungen, deshalb läuft die .cia in der Emulation, aber nicht auf der Hardware. Drei Hauptursachen:
- Unvollständiger SystemCallAccess: Ist ein SVC, den libctru nutzt, nicht deklariert, verweigert der ARM11-Kernel den Aufruf und beendet den Prozess. Nimm alle SVCs von 1 bis 125 auf.
- Fehlender ServiceAccessControl: Ist ein Dienst (etwa
dsp::DSPodergsp::Gpu) nicht deklariert, schlägtsrvGetServiceHandle()fehl und die Initialisierung der Bibliothek stürzt ab. Nimm alle Dienste aus Abschnitt 12 auf. - Logo: None in der RSF: Auf manchen Firmwares führt ein fehlendes Logo im ExeFS zum Absturz beim Start. Nutze
Logo: Nintendozusammen mit dem Flag-exefslogo.
Lösung: Nimm das vollständige RSF-Template aus Abschnitt 12, mit dem kompletten Set an SVCs, Diensten und Berechtigungen. Das ist der zuverlässigste Weg zu einer .cia, die auf echter Hardware läuft.
ndspWaveBuf nicht zurückgesetzt: der Sound spielt nur einmal
Symptom: Ein Soundeffekt läuft beim ersten Mal sauber, danach nie wieder.
Ursache: Nach der Wiedergabe bleibt der status des ndspWaveBuf auf „done“. Vor dem erneuten Abspielen musst du den Status zurücksetzen und den Cache erneut flushen:
snd->wave_buf.status = NDSP_WBUF_FREE; // zurücksetzen
DSP_FlushDataCache(snd->data, snd->size); // erneut flushen
ndspChnWaveBufAdd(channel, &snd->wave_buf); // erneut abspielenromfsInit() vergessen: kein Asset lädt
Symptom: Alle fopen("romfs:/...") liefern NULL. Schriften, Texturen und Sounds laden nicht.
Ursache: romfsInit() wurde am Programmanfang nicht aufgerufen. Dieser Aufruf ist Pflicht, bevor du auf das RomFS zugreifst.
Falsche Maße der CIA-Banner-Assets
Für das CIA-Format sind die Maße strikt:
- Icon: PNG mit exakt 48×48 Pixeln
- Banner: PNG mit exakt 256×128 Pixeln
- Banner-Audio: WAV PCM, möglichst kurz (etwa 3 Sekunden)
Stimmen die Maße nicht, scheitert bannertool stillschweigend oder erzeugt ein kaputtes Binary, an dem die Installation hängen bleibt.
14. Fazit: dein eigenes Homebrew-Spiel für die Nintendo 3DS
Ein Homebrew-Spiel für die Nintendo 3DS zu entwickeln berührt viele spannende Ecken der Programmierung: hardwarenahe Systemprogrammierung, 2D-Grafik mit citro2d, Low-Level-Audio über NDSP, eigenwillige Speicherverwaltung und ARM-Cross-Compilation. Die Grenzen der Plattform (wenig Speicher, zwei Bildschirme, proprietäre Formate) zwingen zu sauberem, effizientem Code, und ehrlich gesagt hat mir genau das riesigen Spaß gemacht.
Die 6 goldenen Regeln der 3DS-Homebrew-Entwicklung
- Trenne Spiellogik und Rendering. Die portable Logik in eine eigene Datei, das 3DS-Rendering in eine andere. Diese Strategie hat die Entwicklung von 2048-3DS überhaupt erst effizient gemacht.
- C2D_TargetClear vor jeder Szene. Immer. Ohne Ausnahme. Sonst: VRAM-Artefakte.
- linearAlloc für Audio, niemals malloc. Und vergiss nie
DSP_FlushDataCache(). - Nur WAV PCM für sämtliche Audiodateien. Konvertiere MP3 und OGG, bevor du sie einbindest.
- Teste die .cia zusätzlich zur .3dsx. Die Berechtigungen unterscheiden sich, und ein in der RSF vergessener Dienst führt zum Absturz.
- Deklariere alle Systemdienste in der RSF-Datei, insbesondere
dsp::DSP,csnd:SND,hid:USERundfs:USER.
Das Projekt 2048 für Nintendo 3DS setzt jedes Konzept aus diesem Tutorial praktisch um: Rendering auf zwei Bildschirmen, flüssige Animationen, mehrspurige Musik, binärer Speicherstand, Lokalisierung in 13 Sprachen, Achievements sowie Packaging als .3dsx und .cia. Ich habe es als Open Source veröffentlicht, damit es als Grundlage für deine eigenen Projekte dient.
2048 für Nintendo 3DS herunterladen, kostenlos als .3dsx und .cia. Wirf einen Blick in den Quellcode, dort siehst du all diese Konzepte in Aktion. Und wenn du selbst ein 3DS-Homebrew-Projekt startest: zeig es ruhig her, die Community ist offen und hilft gern weiter.
Zum Vergleich ein Ansatz ganz ohne eigenen Code: Unser Artikel Spiel mit ChatGPT erstellen begleitet Codex, die Programmier-KI von OpenAI, beim Bau eines kleinen Retro-RPGs im Stil von EarthBound, das im Browser spielbar ist.
Ressourcen für die 3DS-Homebrew-Entwicklung
- devkitPro:
https://devkitpro.org/, offizielle Toolchain für die 3DS-Homebrew-Entwicklung - libctru-Dokumentation:
https://libctru.devkitpro.org/, vollständige Referenz der libctru-API - citro2d-Header: Die Header-Dateien in
$DEVKITARM/../libctru/include/citro2d/sind die beste Dokumentation der Grafikbibliothek - 3dbrew Wiki:
https://www.3dbrew.org/, erschöpfendes Technik-Wiki zur Nintendo 3DS (Hardware, Formate, Systemdienste) - Offizielle Beispiele:
https://github.com/devkitPro/3ds-examples, Beispiele zu Grafik, Audio und Netzwerk - bannertool:
https://github.com/Steveice10/bannertool, Generator für Banner und Icons im CIA-Format - makerom:
https://github.com/3DSGuy/Project_CTR, Assembler für CIA- und CCI-Dateien der Nintendo 3DS
Technische Daten der Nintendo 3DS
- CPU: ARM11 MPCore (ARMv6K) @ 268 MHz, 2 Kerne (4 bei der New 3DS)
- GPU: DMP PICA200 @ 268 MHz
- RAM: 128 MB (256 MB bei der New 3DS), davon 6 MB dedizierter VRAM
- Oberer Bildschirm: 400×240 Pixel (800×240 im stereoskopischen 3D-Modus)
- Unterer Bildschirm: 320×240 Pixel, resistiver Touchscreen
- Audio: 24 DSP-Kanäle, Stereoausgabe


