Cómo desarrollar un juego homebrew para Nintendo 3DS

Cómo desarrollar un juego homebrew para Nintendo 3DS

¿Quieres crear tu propio juego homebrew para Nintendo 3DS? Buenas noticias: sigue siendo perfectamente posible y es un proyecto de lo más divertido. En este tutorial comparto todo lo que aprendí desarrollando 2048 para Nintendo 3DS, un juego homebrew completo con animaciones, audio, 13 idiomas y un sistema de logros, desde la primera línea de código hasta la generación de un archivo .cia instalable.

Mira el resultado final: 2048 para Nintendo 3DS, descarga gratuita en .3dsx y .cia.

Esta guía cubre el pipeline completo del desarrollo homebrew para 3DS: gráficos 2D con citro2d, audio NDSP, pantalla táctil, sistema de archivos RomFS y empaquetado en CIA. Todo el código está en C99. Nada de teoría de relleno: solo código que funciona, sacado directamente del proyecto 2048-3DS.

Índice

  1. Qué es un homebrew de 3DS
  2. Requisitos previos: instalar devkitARM y las herramientas de desarrollo 3DS
  3. Arquitectura de un proyecto homebrew: separar lógica y render
  4. Gestionar la doble pantalla de la Nintendo 3DS
  5. Render gráfico 2D con citro2d
  6. Entradas: cruceta, circle pad y pantalla táctil
  7. Audio homebrew en 3DS: NDSP y formato WAV PCM
  8. Sistema de archivos: RomFS y tarjeta SD
  9. Guardar datos en la tarjeta SD
  10. El Makefile de 3DS: compilación cruzada ARM con devkitARM
  11. Generar un archivo .3dsx para el Homebrew Launcher
  12. Construir un archivo .cia instalable para el menú HOME
  13. Trampas y errores habituales en el desarrollo homebrew para 3DS
  14. Conclusión y recursos para seguir avanzando

1. ¿Qué es un homebrew de Nintendo 3DS?

Un homebrew de 3DS es una aplicación no oficial que se ejecuta en una consola Nintendo 3DS con un custom firmware (CFW) como Luma3DS. La escena homebrew de 3DS permite crear y distribuir libremente juegos, emuladores y utilidades para esta portátil.

La Nintendo 3DS monta un procesador ARM11 (ARMv6K) a 268 MHz y una GPU PICA200, con una particularidad única: dos pantallas. La pantalla superior muestra 400×240 píxeles (800×240 en 3D estereoscópico) y la inferior, táctil, muestra 320×240 píxeles.

Existen dos formatos de distribución para los homebrew de 3DS:

  • .3dsx: formato homebrew que se lanza desde el Homebrew Launcher. Sin instalación, se ejecuta directamente desde la tarjeta SD.
  • .cia: formato instalable que aparece en el menú HOME de la 3DS, con icono y banner animado. Requiere un custom firmware (CFW).

Para 2048-3DS genero los dos formatos, para llegar al máximo de jugadores. La comunidad homebrew de 3DS sigue muy viva gracias a un ecosistema de herramientas sólido: devkitARM (toolchain), libctru (biblioteca de sistema), citro2d/citro3d (gráficos). Si buscas un buen campo de pruebas para aprender programación de sistemas embebidos o desarrollo de juegos retro, la 3DS es perfecta.

2. Requisitos previos: instalar devkitARM y las herramientas de desarrollo 3DS

Antes de ponerte a programar un juego para Nintendo 3DS hay que instalar la toolchain de compilación cruzada ARM y las bibliotecas propias de la consola.

Toolchain devkitARM: el compilador para Nintendo 3DS

devkitARM es la toolchain GCC de compilación cruzada para procesadores ARM que publica el proyecto devkitPro. Incluye el compilador, el linker y las herramientas de build necesarias para compilar código C para la 3DS. Así se instala:

bash
# Instalación en Linux/WSL (recomendado para el desarrollo 3DS)
wget https://apt.devkitpro.org/install-devkitpro-pacman
chmod +x install-devkitpro-pacman
sudo ./install-devkitpro-pacman

# Instalar los paquetes de desarrollo 3DS
sudo dkp-pacman -S 3ds-dev

# Variable de entorno obligatoria
export DEVKITARM=/opt/devkitpro/devkitARM

Bibliotecas esenciales para el desarrollo homebrew en 3DS

  • libctru: biblioteca C de bajo nivel para los servicios de sistema de la 3DS (HID, filesystem, audio, GPU). Es la base de cualquier programa homebrew de 3DS.
  • citro3d: capa de abstracción sobre la GPU PICA200. Gestiona los render targets, el framebuffer y la sincronización gráfica.
  • citro2d: capa 2D construida sobre citro3d. Aporta primitivas sencillas (rectángulos, círculos, elipses, líneas, texto, sprites) ideales para los juegos 2D en 3DS.

Herramientas complementarias para el empaquetado

  • bannertool: genera los archivos banner.bin e icon.bin que necesita el formato CIA
  • makerom: ensambla el archivo .cia final a partir del ELF, el RSF, el banner y el icono
  • tex3ds: convierte imágenes PNG en texturas .t3x que citro2d puede leer en la GPU PICA200
  • mkbcfnt: convierte fuentes TTF al formato .bcfnt para el render de texto en 3DS (admite Unicode, CJK, cirílico)

3. Arquitectura de un proyecto homebrew de 3DS: separar lógica y render

El error clásico cuando empiezas con el desarrollo homebrew es escribir todo el código directamente contra las API de la consola. Resultado: imposible probar o depurar nada como es debido. Yo pasé por ahí, y el consejo que le daría a cualquiera es separar la lógica del juego del render gráfico desde el primer día.

Es exactamente la estrategia que sigue 2048-3DS: la lógica del juego 2048 (rejilla, movimientos, fusiones, puntuación) es 100% portable, mientras que el render es específico de cada plataforma.

Estructura de archivos del proyecto 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)

Código portable: la lógica del juego 2048 sin dependencias

El archivo logic.c de 2048-3DS no incluye ningún header específico de la Nintendo 3DS. Solo usa headers estándar de C (<stdint.h>, <stdlib.h>, <string.h>, <time.h>). El archivo main_3ds.c llama a las funciones públicas de la lógica:

c
// logic.h — interfaz portable del juego 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;   // posición antes del movimiento
    int to_r, to_c;       // posición después del movimiento
    uint16_t value;       // valor mostrado durante la animación
    int merged;           // 1 si hay fusión
} TileAnim;

typedef struct {
    uint16_t cells[GRID_SIZE][GRID_SIZE];  // rejilla 4x4
    uint32_t score;
    int won, over;
    TileAnim anims[MAX_TILE_ANIMS];  // datos de animación de la última jugada
    int anim_count;
    int spawn_r, spawn_c;     // posición de la nueva ficha
    uint16_t spawn_val;        // valor (2 al 90%, 4 al 10%)
} Game;

void game_init(Game *g);                    // rejilla vacía + 2 fichas
int  game_move(Game *g, Direction dir);     // devuelve 1 si la rejilla ha cambiado
int  game_is_over(Game *g);                 // ningún movimiento posible
int  game_has_won(Game *g);                 // ficha >= 2048 alcanzada

El Makefile de 3DS compila logic.c + achievements.c + main_3ds.c y filtra explícitamente main_sdl.c. La lógica del juego es la misma sea cual sea el front-end de render.

4. Gestionar la doble pantalla de la Nintendo 3DS

Lo que distingue a la Nintendo 3DS de cualquier otra portátil es su doble pantalla. Aprovecharla bien es clave para conseguir una buena experiencia homebrew.

  • Pantalla superior (top): 400×240 píxeles, sin táctil. Ideal para el gameplay principal. En 2048-3DS es donde muestro la rejilla 4×4 con las animaciones de las fichas.
  • Pantalla inferior (bottom): 320×240 píxeles, táctil. Ideal para la interfaz, los menús y los controles. En 2048-3DS muestro ahí la puntuación, los botones, los ajustes y los logros.

Inicializar los render targets con citro2d

c
// Inicialización del sistema gráfico de 3DS
gfxInitDefault();
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
C2D_Prepare();

// Crear un render target para cada pantalla
C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);

Bucle de render de doble pantalla

En cada frame hay que dibujar en las dos pantallas por separado. Esta es la estructura del bucle de render de 2048-3DS:

c
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);

// Pantalla superior — rejilla del juego 2048
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_top_screen(&game, anim_phase, progress);

// Pantalla inferior — UI (puntuación, botones, ajustes)
C2D_TargetClear(bot, col_bot_bg);
C2D_SceneBegin(bot);
render_bot_game(&game, best_score, 0, music_muted);

C3D_FrameEnd(0);

Trampa crítica del render en 3DS: C2D_TargetClear es obligatorio. Si no llamas a C2D_TargetClear() antes de cada escena, aparecerán artefactos visuales aleatorios: restos de VRAM del frame anterior. La VRAM de la 3DS no se inicializa sola. Es un bug tremendamente desconcertante cuando empiezas con el homebrew, porque los artefactos cambian en cada frame.

Dimensiones de las pantallas: constantes imprescindibles

c
#define TOP_W  400   // ancho de la pantalla superior
#define TOP_H  240   // alto de la pantalla superior
#define BOT_W  320   // ancho de la pantalla inferior (táctil)
#define BOT_H  240   // alto de la pantalla inferior

Ojo: en la consola física, la pantalla inferior queda centrada horizontalmente bajo la superior (400 frente a 320 píxeles de ancho). Las coordenadas táctiles se corresponden directamente con los píxeles de la pantalla inferior, con el (0,0) arriba a la izquierda.

5. Render gráfico 2D con citro2d en Nintendo 3DS

citro2d es la biblioteca de render 2D de referencia para los juegos homebrew de 3DS. Construida sobre citro3d y la GPU PICA200, gestiona internamente el batching de primitivas y las texturas de GPU. Veamos cómo usarla, con ejemplos reales de 2048-3DS.

Dibujar primitivas: rectángulos, círculos, elipses y líneas

c
// Rectángulo relleno (fichas del juego 2048)
C2D_DrawRectSolid(x, y, z, largeur, hauteur, couleur);

// Círculo relleno (esquinas redondeadas, iconos)
C2D_DrawCircleSolid(centre_x, centre_y, z, rayon, couleur);

// Elipse rellena (icono de música en 2048-3DS)
C2D_DrawEllipseSolid(x, y, z, largeur, hauteur, couleur);

// Línea (barra de silencio del audio)
C2D_DrawLine(x1, y1, couleur1, x2, y2, couleur2, epaisseur, z);

El parámetro z controla la profundidad (0.0f para el plano estándar). Los colores en 3DS usan el formato ABGR en memoria (cuidado, es el inverso del ARGB de toda la vida). citro2d ofrece la macro C2D_Color32(r, g, b, a) para construirlos correctamente:

c
// Trampa de C99: C2D_Color32() no es constexpr
// Usar una macro para las constantes de color globales
#define MAKE_COLOR(r,g,b,a) \
    ((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))

// Paleta de colores del juego 2048 en 3DS
#define col_grid_bg  MAKE_COLOR(0xBB, 0xAD, 0xA0, 0xFF)  // fondo de la rejilla
#define col_bot_bg   MAKE_COLOR(0xFA, 0xF8, 0xEF, 0xFF)  // fondo de la pantalla inferior
#define col_text_dk  MAKE_COLOR(0x77, 0x6E, 0x65, 0xFF)  // texto oscuro
#define col_text_lt  MAKE_COLOR(0xF9, 0xF6, 0xF2, 0xFF)  // texto claro

Rectángulos redondeados: un truco gráfico para citro2d

citro2d no trae ninguna primitiva de «rectángulo redondeado». Para las fichas del juego 2048 y los botones de la interfaz monté esta técnica, que combina un rectángulo central, dos rectángulos laterales y cuatro círculos en las esquinas:

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;
    }
    // Cuerpo central + bordes laterales
    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 círculos de radio r en las esquinas
    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);
}

Esta función se usa por todas partes en 2048-3DS: fichas del juego, cajas de puntuación, botones, sliders de volumen y diálogos de confirmación.

Mostrar texto en Nintendo 3DS con el formato .bcfnt

El render de texto en 3DS pasa por el formato .bcfnt (Binary CTR Font). Cada cadena hay que «parsearla» a un buffer de texto antes de dibujarla. En 2048-3DS esta técnica sirve para mostrar las puntuaciones, los nombres de los botones en 13 idiomas y las notificaciones de logros:

c
// Inicialización de los buffers de texto
C2D_TextBuf s_dynamicBuf = C2D_TextBufNew(512);
C2D_Font s_font = C2D_FontLoad("romfs:/font.bcfnt");

// Función utilitaria: dibujar texto centrado
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);
}

// Importante: vaciar el buffer de texto en cada frame
C2D_TextBufClear(s_dynamicBuf);

El factor 0.62f está ajustado para que el centrado vertical del texto quede visualmente correcto.

Sprites y texturas de GPU en formato .t3x

Las imágenes hay que convertirlas al formato .t3x (textura 3DS optimizada para la GPU PICA200) con la herramienta tex3ds. Un archivo .t3s describe el sprite sheet. En 2048-3DS, los 8 iconos de logros (32×32 píxeles cada uno) van empaquetados en un único 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
// Carga del sprite sheet desde RomFS
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");

// Mostrar un icono de logro
C2D_Image img = C2D_SpriteSheetGetImage(sheet, index);
C2D_DrawImageAt(img, x, y, 0.0f, NULL, scale_x, scale_y);

// Mostrar en escala de grises (logro bloqueado)
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);

Animaciones fluidas en Nintendo 3DS

Para conseguir animaciones fluidas en un juego homebrew de 3DS, usa osGetTime() (tiempo en milisegundos) e interpolación con easing. Este es el sistema de animación de 2048-3DS para el deslizamiento y la aparición de las fichas:

c
// Timing de las animaciones del juego 2048
#define ANIM_SLIDE_MS  120    // duración del deslizamiento de la ficha
#define ANIM_POP_MS    100    // duración del "pop" (fusión/aparición)

typedef enum { ANIM_NONE, ANIM_SLIDING, ANIM_POPPING } AnimPhase;

// Easing cuadrático: deceleración natural
static float ease_out_quad(float t) {
    return t * (2.0f - t);
}

// En el bucle principal: interpolación de las posiciones
u64 now = osGetTime();
u64 elapsed = now - anim_start;
float progress = (float)elapsed / ANIM_SLIDE_MS;
float t = ease_out_quad(progress);

// Deslizamiento lineal de la ficha
float cur_x = from_px + (to_px - from_px) * t;
float cur_y = from_py + (to_py - from_py) * t;

La animación tiene dos fases: primero el deslizamiento (120 ms) y luego el «pop»: las fichas fusionadas crecen hasta el 125% antes de volver al 100%, y la ficha nueva aparece creciendo del 0 al 100%. Este sistema hace que el 2048 en 3DS resulte tan satisfactorio a la vista como la versión original.

6. Entradas en Nintendo 3DS: cruceta, circle pad y pantalla táctil

La Nintendo 3DS ofrece varios periféricos de entrada a los juegos homebrew, todos accesibles desde la biblioteca libctru (HID). En 2048-3DS uso los tres: cruceta y circle pad para mover las fichas, y pantalla táctil para los botones y los sliders.

Leer los botones físicos: cruceta y teclas A/B/X/Y

c
hidScanInput();            // Escanear el estado de las entradas (1x por frame)
u32 kDown = hidKeysDown(); // Botones pulsados en este frame

// Cruceta: mover las fichas del juego 2048
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; }

// Botones de sistema
if (kDown & KEY_B)      { /* navegación atrás */ }
if (kDown & KEY_START)  { break; /* salir de la aplicación */ }
if (kDown & KEY_SELECT) { game_init(&game); /* nueva partida */ }

Circle Pad: el stick analógico de la 3DS y su deadzone

c
circlePosition cpad;
hidCircleRead(&cpad);

// Deadzone de 80 para evitar los falsos positivos
// Rango del circle pad: entre -155 y +155 aprox.
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; }

La deadzone es crítica en el circle pad de la 3DS. Sin umbral, el stick registra micromovimientos de forma continua y dispara movimientos no deseados. Un valor de 80 (sobre un rango de unos -155 a +155) funciona bien en la práctica para un juego como el 2048.

Pantalla táctil: gestionar los toques en los botones de la interfaz

La pantalla táctil de la 3DS es resistiva (por presión, no capacitiva). Las coordenadas van directamente en píxeles de la pantalla inferior (320×240). En 2048-3DS, el táctil se encarga de los botones «Nueva partida», «Logros» y «Ajustes», de los sliders de volumen y de la selección de idioma:

c
if (kDown & KEY_TOUCH) {
    touchPosition touch;
    hidTouchRead(&touch);
    int mx = touch.px;  // 0..319
    int my = touch.py;  // 0..239

// Detección de toque en el botón "Nueva partida"
    float btn_x = (BOT_W - BTN_W) / 2;  // centrado horizontalmente
    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;
    }
}

Usa hidKeysDown() con KEY_TOUCH para las pulsaciones puntuales (botones) y hidKeysHeld() para las interacciones continuas (sliders de volumen, arrastre).

Bucle principal de un juego homebrew de 3DS

c
while (aptMainLoop()) {
    u64 now = osGetTime();
    audio_music_tick(music_volume, music_muted);

hidScanInput();
    u32 kDown = hidKeysDown();

if (kDown & KEY_START) break;  // Salida limpia

// Procesar las entradas (cruceta, circle pad, táctil)
    // Actualizar la lógica del juego 2048
    // Render de las dos pantallas
    // ...
}

aptMainLoop() gestiona el ciclo de vida de la aplicación homebrew: suspensión, cierre desde el sistema y vuelta al menú HOME.

7. Audio homebrew en 3DS: NDSP y formato WAV PCM

Créeme por experiencia: el audio en Nintendo 3DS es lo que más tiempo me hizo perder en todo el desarrollo homebrew. Sobre el papel parece sencillo, pero las trampas están por todas partes. El backend de audio de la 3DS es NDSP (Nintendo DSP): necesita el firmware del DSP (dspfirm.bin en la tarjeta SD) y admite mezcla multicanal con interpolación lineal.

En 2048-3DS implementé la música de fondo en 4 pistas y los efectos de sonido de los movimientos de ficha y de los logros.

Formato de audio para la 3DS: WAV PCM obligatorio

Trampa principal del audio en 3DS: la consola solo admite WAV PCM sin comprimir (nada de MP3, ni OGG, ni WAV comprimido en ADPCM). Los archivos deben ser PCM de 8 o 16 bits, mono o estéreo. Cualquier otro formato se ignorará en silencio o provocará un crash.

Reservar memoria de audio en 3DS: linearAlloc en lugar de malloc

Los buffers de audio en 3DS hay que reservarlos con linearAlloc(), nunca con malloc(). La memoria lineal es accesible directamente por el procesador DSP, la memoria del heap estándar, no.

c
// Estructura para almacenar un archivo de audio WAV en 3DS
typedef struct {
    u8  *data;            // Datos PCM (reservados con linearAlloc)
    u32  size;            // Tamaño en bytes
    u32  sample_rate;     // Frecuencia de muestreo
    u16  channels;        // 1 (mono) o 2 (estéreo)
    u16  bits_per_sample; // 8 o 16 bits
    ndspWaveBuf wave_buf; // Buffer NDSP
    int  loaded;          // Flag de carga correcta
} WavSound;

Cargar archivos WAV en un homebrew de 3DS

c
static int wav_load(WavSound *snd, const char *path)
{
    FILE *f = fopen(path, "rb");
    if (!f) return -1;

// Leer y validar la cabecera RIFF/WAVE
    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;
    }

// Recorrer los chunks WAV (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) {
            // Extraer channels, sample_rate, bits_per_sample
            // ...
        } else if (memcmp(chunk_id, "data", 4) == 0) {
            // IMPORTANTE: ¡linearAlloc, no malloc!
            snd->data = (u8 *)linearAlloc(chunk_size);
            fread(snd->data, 1, chunk_size, f);
        } else {
            fseek(f, chunk_size, SEEK_CUR);
        }
    }

// Preparar el buffer NDSP
    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;

// OBLIGATORIO: vaciar la caché de la CPU hacia el DSP
    DSP_FlushDataCache(snd->data, snd->size);
    return 0;
}

DSP_FlushDataCache es obligatorio después de cada escritura en un buffer de audio de la 3DS. Sin esa llamada, el DSP lee datos corruptos porque la caché de la CPU no es coherente con la memoria del DSP. Es la causa número 1 de bugs de audio silenciosos en Nintendo 3DS.

Inicialización del audio NDSP

Esta es la inicialización de audio de 2048-3DS:

c
static void audio_init(void)
{
    if (ndspInit() != 0) return;

ndspSetOutputMode(NDSP_OUTPUT_STEREO);

// Canal 0: música de fondo
    // Canal 1: SFX (movimiento de ficha)
    // Canal 2: SFX (logro desbloqueado)
    for (int ch = 0; ch < 3; ch++) {
        ndspChnReset(ch);
        ndspChnSetInterp(ch, NDSP_INTERP_LINEAR);
        ndspChnSetFormat(ch, NDSP_FORMAT_STEREO_PCM16);
    }
}

Reproducir un sonido y controlar el volumen en la 3DS

c
// NDSP: añadir el buffer de audio al canal
snd->wave_buf.status = NDSP_WBUF_FREE;  // reinicializar el status
DSP_FlushDataCache(snd->data, snd->size);
ndspChnWaveBufAdd(channel, &snd->wave_buf);

// Volumen NDSP: array de 12 floats (mezcla estéreo por canal)
float vol = (float)music_volume / 128.0f;  // 0..128 -> 0.0..1.0
float mix[12] = {0};
mix[0] = vol;  // canal izquierdo
mix[1] = vol;  // canal derecho
ndspChnSetMix(0, mix);

Encadenar pistas musicales automáticamente

En 2048-3DS se encadenan 4 pistas de forma automática (intro, music1, music2, music3). En cada frame compruebo si la pista actual ha terminado:

c
static void audio_music_tick(int music_volume, int music_muted)
{
    if (!s_audio_init || music_muted) return;

if (!ndspChnIsPlaying(0)) {
        // Pasar a la pista siguiente (bucle cíclico)
        s_music_current = (s_music_current + 1) % MUSIC_TRACK_COUNT;
        audio_play_current_track(music_volume, music_muted);
    }
}

Liberar el audio al terminar el programa

c
// Liberar correctamente los recursos de audio
ndspChnReset(0);
ndspChnReset(1);
ndspChnReset(2);
ndspExit();

// Liberar la memoria lineal (¡no free, sino 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. Sistema de archivos de la 3DS: RomFS y tarjeta SD

La Nintendo 3DS ofrece dos sistemas de archivos a las aplicaciones homebrew: RomFS para los assets de solo lectura y SDMC para lectura y escritura en la tarjeta SD.

RomFS: empotrar los assets en el binario homebrew

RomFS (Read-Only Memory FileSystem) permite empotrar archivos directamente en el binario .3dsx o .cia. Es ideal para los assets que no cambian: fuentes, texturas, música, sprites. En 2048-3DS, la carpeta romfs/ contiene la fuente .bcfnt con soporte CJK, el sprite sheet de los logros y 6 archivos de audio WAV:

c
// Inicialización obligatoria antes de cualquier acceso a RomFS
romfsInit();

// Acceso a los assets con el prefijo 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");

El contenido de la carpeta romfs/ lo empotra automáticamente el Makefile de 3DS:

bash
# En Makefile.3ds
ROMFS := romfs

Formatos de assets propios de la Nintendo 3DS

  • .bcfnt: fuente bitmap compilada con mkbcfnt. Admite Unicode completo (CJK, cirílico, caracteres acentuados). En 2048-3DS, una sola fuente cubre los 13 idiomas.
  • .t3x: textura de GPU compilada con tex3ds. Formato optimizado para la PICA200. Se carga directamente en VRAM sin conversión.
  • .wav: audio PCM sin comprimir. No hay conversión en tiempo de ejecución, pero tiene que ser PCM sin comprimir (ver la sección de audio).

SDMC: guardar datos en la tarjeta SD de la 3DS

Para guardar datos (puntuaciones, ajustes, progreso), los homebrew de 3DS escriben en la tarjeta SD con el prefijo sdmc:/:

c
// Rutas de guardado para 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"

// Crear el directorio de guardado al arrancar
#include <sys/stat.h>
mkdir("sdmc:/3ds", 0777);
mkdir(SAVE_DIR, 0777);

La escritura usa las funciones estándar de C (fopen, fwrite, fclose). No hace falta ninguna API específica de la 3DS para las operaciones de archivo en SDMC.

9. Guardar datos en la tarjeta SD desde un homebrew de 3DS

En un juego homebrew de 3DS, el guardado binario es el método más directo y más económico. Así implementa 2048-3DS la persistencia de los logros y de los ajustes.

Guardado binario del sistema de logros

El juego 2048-3DS tiene 8 logros (umbrales de puntuación: 500, 1000, 2500, 5000, 10000, 20000, 50000, 100000 puntos). El guardado ocupa 8 bytes, uno por logro:

c
// Guardado: 8 bytes (0x00 = bloqueado, 0x01 = desbloqueado)
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;
}

// Carga con valores por defecto si el archivo no existe
int achievements_load(Achievements *a, const char *path)
{
    achievements_init(a);  // todos bloqueados por defecto
    FILE *f = fopen(path, "rb");
    if (!f) return -1;     // primera ejecución: no hay archivo
    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;
}

Guardado de los ajustes del usuario

Los ajustes de 2048-3DS (idioma, volumen de la música, volumen de los efectos, estado de silencio) se guardan en 4 bytes:

c
// Formato: [0] = idioma, [1] = volumen de música, [2] = volumen de SFX, [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);
}

Ventajas de este enfoque en un juego homebrew: tamaño fijo, sin parsing, sin depender de una biblioteca JSON/XML y lectura instantánea. Los ajustes se guardan automáticamente cuando el usuario sale del menú de ajustes o de la aplicación.

10. El Makefile de 3DS: compilación cruzada ARM con devkitARM

El sistema de build de los homebrew de 3DS se apoya en las reglas que aporta devkitARM. Entender el Makefile de 3DS es imprescindible para depurar los problemas de compilación.

Estructura del Makefile de un proyecto homebrew de 3DS

bash
# Comprobar que devkitARM está configurado
ifeq ($(strip $(DEVKITARM)),)
$(error "Please set DEVKITARM in your environment")
endif

include $(DEVKITARM)/3ds_rules

# Configuración del proyecto homebrew
TARGET   := 2048
BUILD    := build_3ds
SOURCES  := source
ROMFS    := romfs

# Metadatos mostrados en el Homebrew Launcher y en el menú HOME
APP_TITLE       := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR      := Gekkode

Flags de compilación ARM para la 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__

Qué hace cada flag de la compilación cruzada ARM para 3DS:

  • -march=armv6k: arquitectura del procesador ARM11 de la Nintendo 3DS
  • -mtune=mpcore: optimiza el código para el core MPCore
  • -mfloat-abi=hard: usa la VFP (coma flotante por hardware), crucial para el rendimiento gráfico
  • -mword-relocations: genera las relocalizaciones que exige el formato 3DSX
  • -ffunction-sections: permite al linker eliminar el código muerto y reducir el tamaño del binario
  • -D__3DS__: define la macro para los #ifdef condicionales entre PC y 3DS

Enlazado de las bibliotecas citro2d y libctru

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

El orden de las bibliotecas importa para el linker de GNU: -lcitro2d depende de -lcitro3d, que depende de -lctru. Ordena siempre de la abstracción más alta a la más baja.

Filtrar las fuentes: excluir el render de PC del build de 3DS

bash
# Excluir main_sdl.c del build 3DS (solo se compila main_3ds.c)
CFILES := $(filter-out main_sdl.c, \
    $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))))

Este filtro garantiza que solo se compile main_3ds.c en el build de 3DS.

Comandos de build

bash
# Compilar el homebrew en .3dsx
make -f Makefile.3ds

# Compilar y generar un .cia instalable
make -f Makefile.3ds cia

# Limpieza completa
make -f Makefile.3ds clean

11. Generar un archivo .3dsx para el Homebrew Launcher

El formato .3dsx es el formato homebrew estándar de la Nintendo 3DS y se lanza desde el Homebrew Launcher. Es el más sencillo de distribuir: un único archivo que copiar en la tarjeta SD.

El Makefile de 3DS genera automáticamente dos archivos:

  • 2048.3dsx: el ejecutable homebrew, con el RomFS empotrado
  • 2048.smdh: los metadatos SMDH (título, autor, descripción, icono)

SMDH: los metadatos de tu homebrew de 3DS

El archivo SMDH (Simple Metadata Header) contiene la información que se muestra en el Homebrew Launcher:

bash
# Definidos en el Makefile.3ds
APP_TITLE       := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR      := Gekkode

El icono es un PNG de 48×48 píxeles, detectado automáticamente si existe icon.png o 2048.png en la raíz del proyecto.

Empotrar el RomFS en el .3dsx

El RomFS se integra directamente en el archivo .3dsx. El usuario final solo tiene que manejar un archivo:

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

12. Construir un archivo .cia instalable para el menú HOME de la 3DS

El formato .cia (CTR Importable Archive) es el formato instalable de la Nintendo 3DS. Una vez instalado con FBI u otro gestor de títulos, el juego aparece en el menú HOME de la consola con icono propio, banner animado y sonido de arranque.

Es el formato que uso para distribuir 2048 para Nintendo 3DS en versión .cia.

Atención: es con diferencia la etapa más delicada del desarrollo homebrew en 3DS. Me pasé un tiempo enorme intentando entender por qué un archivo .cia podía funcionar perfectamente en el emulador Citra y petar al instante en una 3DS real. La causa casi siempre es un archivo RSF mal configurado. Lo que viene a continuación es el resultado de muchas horas de pruebas en hardware real: detallo cada parámetro para que tú no tengas que sufrirlo.

Assets necesarios para construir un .cia

  • Icono: PNG de 48×48 píxeles exactos (se ve en el menú HOME)
  • Imagen del banner: PNG de 256×128 píxeles exactos (se muestra en la parte superior de la pantalla al seleccionar el juego)
  • Sonido del banner: WAV PCM de 16 bits, 44100 Hz, estéreo, de unos 3 segundos (suena al seleccionar el juego en el menú HOME). El formato es estricto: usa ffmpeg para convertirlo:
bash
# Convertir cualquier audio en WAV compatible con bannertool
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wav

Pasos para construir un archivo CIA para la 3DS

Construir un .cia son 4 pasos. Lo recomendable es partir de una plantilla RSF con variables $(VARIABLE) que se sustituyen mediante los flags -DVARIABLE="valeur" de makerom. Así separas la configuración (en el Makefile) de la plantilla de permisos (en el RSF), y el build queda más limpio y reutilizable:

bash
# 1. Construir el binario ELF
make -f Makefile.3ds

# 2. Generar el banner (imagen + audio de arranque)
bannertool makebanner \
    -i assets/banner.png \
    -a assets/music/banner.wav \
    -o build_3ds/banner.bnr

# 3. Generar el icono SMDH para el menú HOME
bannertool makesmdh \
    -s "2048" \
    -l "2048 - puzzle game for 3DS" \
    -p "Gekkode" \
    -i icon.png \
    -o build_3ds/icon.icn

# 4. Ensamblar el .cia final con 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"

Cada flag -DXXX="valeur" sustituye la variable $(XXX) correspondiente del archivo RSF. Este sistema de sustitución permite tener una única plantilla RSF reutilizable para todos tus proyectos: solo cambian los flags -D de un proyecto a otro.

Los flags importantes de makerom:

  • -target t: objetivo «test» (para los homebrew sin firma de Nintendo)
  • -exefslogo: incluye el logo en el ExeFS (necesario para el splash screen de arranque)
  • -major / -minor / -micro: versión del título (se muestra en los ajustes del sistema)

El archivo RSF: configurar los permisos de tu homebrew en CIA

El archivo RSF (ROM Specification File) es el archivo más crítico del build de un CIA. Define los metadatos, los permisos de sistema, los servicios autorizados y las llamadas al sistema disponibles. Un RSF incompleto es un crash inmediato en hardware real, aunque todo funcione en Citra.

Esta es la plantilla RSF completa de 2048-3DS, afinada tras muchas pruebas en hardware real. Cada sección se explica a continuación:

BasicInfo: identidad del título

yaml
BasicInfo:
  Title                   : $(APP_TITLE)
  ProductCode             : $(APP_PRODUCT_CODE)
  Logo                    : Nintendo
  • Title: el nombre que aparece en los ajustes del sistema. Usa una variable $(APP_TITLE) sustituida por -DAPP_TITLE="2048".
  • ProductCode: un identificador con el formato CTR-H-XXXX. La H significa homebrew. Elige un código único (por ejemplo CTR-H-2048).
  • Logo: el splash screen animado que se ve al arrancar.
    • Nintendo: el logo animado oficial de 3DS (recomendado, es el comportamiento estándar)
    • Homebrew: el logo de la comunidad homebrew
    • Licensed / Distributed: variantes del logo de Nintendo
    • None: evítalo, puede provocar crashes en algunas versiones de firmware

RomFs: recursos empotrados

c
RomFs:
  RootPath: $(APP_ROMFS)

Ruta a la carpeta romfs/ con los assets empotrados (sprites, fuentes, audio). Se pasa con -DAPP_ROMFS="romfs". Si tu aplicación no tiene RomFS, puedes omitir esta sección.

TitleInfo: identificación única

yaml
TitleInfo:
  Category                : $(APP_CATEGORY)
  UniqueId                : $(APP_UNIQUE_ID)
  • Category: siempre Application para un juego o una utilidad homebrew.
  • UniqueId: identificador hexadecimal único dentro del rango 0xF0000 a 0xFFFFF (rango reservado al homebrew). Cada homebrew instalado en la consola debe tener un UniqueId distinto para evitar conflictos. Para 2048 uso 0xF2048.

Option: opciones de empaquetado

c
Option:
  UseOnSD                 : $(APP_USE_ON_SD)
  FreeProductCode         : true
  MediaFootPadding        : false
  EnableCrypt             : $(APP_ENCRYPTED)
  EnableCompress          : true
  • UseOnSD: true (obligatorio para un homebrew instalado en la tarjeta SD).
  • FreeProductCode: true para que makerom acepte un ProductCode libre (sin comprobar el formato de Nintendo).
  • EnableCrypt: false (los homebrew no tienen claves de cifrado de Nintendo).
  • EnableCompress: true para comprimir la sección .code del ExeFS (reduce el tamaño del .cia).

AccessControlInfo: la sección crítica

Aquí se juega el 90% de los crashes de un CIA. Esta sección define todo lo que tu aplicación tiene derecho a hacer en la consola. Si falta una sola llamada al sistema o un solo servicio, el CIA peta al instante en hardware real, mientras que Citra ignora esas restricciones.

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

Los parámetros que hay que adaptar a tu proyecto:

  • MemoryType: Application para un juego o una utilidad estándar. System para un módulo de sistema.
  • SystemMode: cantidad de RAM asignada. 64MB es el estándar. Usa 96MB solo en aplicaciones exclusivas de New 3DS que necesiten más memoria.
  • SystemModeExt: Legacy para compatibilidad con Old 3DS y New 3DS.
  • CpuSpeed: 268MHz activa el boost de frecuencia en New 3DS (baja a 268 MHz en Old 3DS, se gestiona de forma automática).
  • EnableL2Cache: false por defecto. Ponlo a true en aplicaciones intensivas en CPU sobre New 3DS (puede causar inestabilidad en Old 3DS).

Después vienen las cuatro subsecciones de permisos. Mi consejo: incluye el set completo. Un homebrew básico no usa todos esos SVC ni todos esos servicios, pero declarar de más no supone ningún problema de rendimiento ni de seguridad en una consola con CFW, olvidar uno solo, en cambio, provoca un crash inmediato:

FileSystemAccess: acceso al sistema de archivos

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

Los más importantes para un homebrew básico: DirectSdmc + DirectSdmcWrite (lectura y escritura en SD) y Core. Aun así, es más seguro incluir el set completo.

IoAccessControl: acceso de E/S de bajo nivel

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

SystemCallAccess: llamadas al sistema de ARM11 (SVC)

Esta lista define qué llamadas al sistema (supervisor calls) puede usar tu aplicación. Es la sección más crítica: si falta un SVC y libctru lo llama, la consola peta al instante. Incluye todos los SVC del 1 al 125:

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

Importante: los números no son continuos, hay huecos (63-70, 89-95, 110-111, 116). Es normal: son SVC reservados o no implementados por el kernel de la 3DS.

ServiceAccessControl: servicios del sistema

Los servicios son las API de alto nivel de la 3DS. Cada biblioteca de libctru usa uno o varios servicios. Si un servicio no está declarado aquí, la llamada a srvGetServiceHandle() falla y la inicialización de la biblioteca correspondiente peta:

yaml
  ServiceAccessControl:
   - APT:U          # Ciclo de vida de la aplicación (aptMainLoop)
   - ac:u           # Configuración de red
   - am:net         # Application Manager (instalación de títulos)
   - boss:U         # SpotPass
   - cam:u          # Cámara
   - cecd:u         # StreetPass
   - cfg:nor        # Configuración NOR
   - cfg:u          # Configuración del sistema (idioma, región)
   - csnd:SND       # Audio CSND
   - dsp::DSP       # Audio NDSP (backend principal)
   - frd:u          # Lista de amigos
   - fs:USER        # Sistema de archivos (SDMC, RomFS)
   - gsp::Gpu       # GPU PICA200 (gráficos)
   - gsp::Lcd       # Control de la pantalla LCD
   - hid:USER       # Entrada (botones, pad, táctil)
   - http:C         # Cliente HTTP
   - ir:rst         # Infrarrojos (C-stick de New 3DS)
   - ir:u           # Infrarrojos genérico
   - ir:USER        # Infrarrojos de usuario
   - mic:u          # Micrófono
   - mcu::HWC       # Microcontrolador del hardware
   - ndm:u          # Network Daemon Manager
   - news:s         # Notificaciones
   - nwm::EXT       # Network Manager extendido
   - nwm::UDS       # Network Manager UDS (wireless local)
   - ptm:sysm       # Power/Timer Manager del sistema
   - ptm:u          # Power/Timer Manager de usuario
   - pxi:dev        # Acceso al dispositivo PXI
   - soc:U          # Sockets (red TCP/UDP)
   - ssl:C          # SSL/TLS
   - y2r:u          # Conversión de YUV a RGB

Para un juego homebrew básico, los servicios imprescindibles son: APT:U, fs:USER, gsp::Gpu y hid:USER. Añade dsp::DSP si usas audio y cfg:u si lees la configuración del sistema. Pero, igual que con los SVC, es más seguro incluir el set completo: declarar un servicio que no usas no penaliza en nada.

SystemControlInfo: pila, guardado y dependencias

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 si guardas directamente en la SD (lo que hace la mayoría del homebrew). Pon un tamaño si usas la API de guardado del sistema.
  • StackSize: 0x40000 (256 KB) es un valor por defecto sólido. Auméntalo si tu app hace recursión intensa o reserva arrays grandes en la pila.
  • Dependency: los title ID de los módulos de sistema de los que depende tu aplicación. Atención: los title ID NO deben llevar el sufijo L. Escribir 0x0004013000002402L en lugar de 0x0004013000002402 puede provocar errores silenciosos. Copia la lista anterior tal cual.

IORegisterMapping y MemoryMapping

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

Estas secciones definen los rangos de memoria y los registros de E/S accesibles. Copia los valores anteriores sin tocarlos: cubren las necesidades de cualquier homebrew estándar.

Adaptar el RSF a tu proyecto

La plantilla RSF anterior es genérica y reutilizable. Para adaptarla a un proyecto nuevo basta con cambiar los flags -D del comando makerom:

Flag -DValor en 2048Qué adaptar
APP_TITLE2048Nombre de tu app
APP_PRODUCT_CODECTR-H-2048CTR-H-XXXX único
APP_UNIQUE_ID0xF2048Hex único dentro de 0xF0000-0xFFFFF
APP_ROMFSromfsRuta de tu carpeta romfs
APP_SYSTEM_MODE64MB96MB si es exclusivo de New 3DS
APP_VERSION_MAJOR1Número de versión mayor

Los demás valores (APP_ENCRYPTED=false, APP_CATEGORY=Application, APP_USE_ON_SD=true, etc.) son los mismos para todos los homebrew.

Automatizar el build del CIA en el Makefile

bash
# Variables al principio del 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

# Objetivo CIA — un solo "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)"

Extensiones .bnr e .icn: los archivos que genera bannertool llevan las extensiones .bnr (banner) e .icn (icono). Algunos tutoriales usan .bin, pero las extensiones correctas son .bnr e .icn: es lo que hacen las buildtools de referencia.

13. Trampas y errores habituales en el desarrollo homebrew para 3DS

Me di de bruces con todos estos muros mientras desarrollaba 2048-3DS, y me habría encantado tener esta lista a mano desde el principio. Estos son los errores más frustrantes y cómo evitarlos: te ahorrarán unas cuantas horas de depuración al crear tu primer juego homebrew para Nintendo 3DS.

Artefactos de VRAM: el bug fantasma de la pantalla de la 3DS

Síntoma: aparecen píxeles de colores aleatorios en la pantalla, distintos en cada frame.

Causa: te has olvidado de C2D_TargetClear() antes de C2D_SceneBegin(). La VRAM de la 3DS no se inicializa sola: contiene restos del frame anterior o datos cualesquiera.

c
// INCORRECTO — artefactos de VRAM garantizados
C2D_SceneBegin(top);
render_game();

// CORRECTO — borrar siempre antes de dibujar
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_game();

linearAlloc frente a malloc: la trampa de la memoria de audio en 3DS

Síntoma: no hay sonido o se oyen ruidos parásitos.

Causa: los buffers de audio en 3DS hay que reservarlos con linearAlloc(). La memoria que devuelve malloc() no es accesible por el procesador DSP. También hay que liberarla con linearFree(), no con free().

c
// INCORRECTO — el DSP no puede leer esta memoria
u8 *audio_buf = malloc(size);

// CORRECTO — memoria lineal accesible por el DSP
u8 *audio_buf = linearAlloc(size);
// ... uso ...
linearFree(audio_buf);

Olvidar DSP_FlushDataCache: audio corrupto en 3DS

Síntoma: el sonido suena corrupto, desincronizado o directamente mal.

Causa: la caché de la CPU y la memoria del DSP de la 3DS no son coherentes. Hay que vaciar la caché explícitamente después de escribir en un buffer de audio:

c
fread(snd->data, 1, chunk_size, f);
DSP_FlushDataCache(snd->data, snd->size);  // ¡OBLIGATORIO!

Formato de audio incorrecto: silencio absoluto

Síntoma: no se oye nada, o crash al cargar.

Causa: el archivo WAV está comprimido (ADPCM, MP3 encapsulado, etc.) en lugar de estar en PCM sin comprimir. La 3DS solo admite PCM sin comprimir. Comprobar y convertir:

bash
# Comprobar el códec del archivo WAV
ffprobe music.wav 2>&1 | grep "Audio:"
# Debe mostrar: pcm_s16le (16 bits) o pcm_u8 (8 bits)

# Convertir un MP3 en WAV PCM compatible con 3DS
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 22050 music.wav

C2D_Color32 no es constexpr en C99

Síntoma: error de compilación «initializer element is not constant» con citro2d en C99.

Causa: C2D_Color32() es una función inline en C, no una expresión constante. No se puede usar para inicializar variables globales en C99. Solución:

c
// INCORRECTO en C99
static u32 my_color = C2D_Color32(0xFF, 0x00, 0x00, 0xFF);

// CORRECTO — macro MAKE_COLOR en lugar de 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);

El .3dsx funciona pero el .cia peta: permisos del RSF

Síntoma: el homebrew funciona perfectamente en .3dsx desde el Homebrew Launcher, pero peta al instante en .cia (a menudo con el mensaje «ErrDisp: An error has occurred» o «SD Card was removed»). El .cia sí funciona en Citra.

Causa: el formato .3dsx hereda los permisos amplios del Homebrew Launcher. El formato .cia tiene sus propios permisos, definidos en el archivo RSF. El emulador Citra ignora las restricciones de permisos, y por eso el .cia funciona en emulación pero no en el hardware. Hay tres causas principales:

  • SystemCallAccess incompleto: si un SVC que usa libctru no está declarado, el kernel ARM11 rechaza la llamada y mata el proceso. Incluye todos los SVC del 1 al 125.
  • ServiceAccessControl incompleto: si un servicio (por ejemplo dsp::DSP o gsp::Gpu) no está declarado, srvGetServiceHandle() falla y la inicialización de la biblioteca correspondiente peta. Incluye todos los servicios listados en la sección 12.
  • Logo: None en el RSF: en algunos firmwares, la ausencia de logo en el ExeFS provoca un crash al arrancar. Usa Logo: Nintendo con el flag -exefslogo.

Solución: usa la plantilla RSF completa de la sección 12, con el set completo de SVC, servicios y permisos. Es la forma más fiable de conseguir un .cia que funcione en hardware real.

ndspWaveBuf sin reinicializar: el sonido solo suena una vez

Síntoma: un efecto de sonido suena bien la primera vez y luego nunca más.

Causa: después de la reproducción, el status del ndspWaveBuf se queda en «done». Hay que reinicializar el status y volver a vaciar la caché antes de reproducirlo otra vez:

c
snd->wave_buf.status = NDSP_WBUF_FREE;    // reinicializar
DSP_FlushDataCache(snd->data, snd->size);  // volver a vaciar la caché
ndspChnWaveBufAdd(channel, &snd->wave_buf); // volver a reproducir

Olvidar romfsInit(): no carga ningún asset

Síntoma: todos los fopen("romfs:/...") devuelven NULL. No cargan ni las fuentes, ni las texturas, ni los sonidos.

Causa: no se ha llamado a romfsInit() al principio del programa. Esa llamada es obligatoria antes de cualquier acceso al sistema de archivos RomFS.

Dimensiones incorrectas en los assets del banner del CIA

Las dimensiones son estrictas en el formato CIA:

  • Icono: PNG de 48×48 píxeles exactos
  • Banner: PNG de 256×128 píxeles exactos
  • Audio del banner: WAV PCM, preferiblemente corto (~3 segundos)

Si las dimensiones no coinciden, bannertool fallará en silencio o generará un binario corrupto que hará que la instalación se venga abajo.

14. Conclusión: crea tu propio juego homebrew para Nintendo 3DS

Desarrollar un juego homebrew para Nintendo 3DS es un proyecto que toca un montón de áreas apasionantes de la programación: programación de sistemas embebidos, gráficos 2D con citro2d, audio de bajo nivel con NDSP, gestión de memoria específica y compilación cruzada ARM. Las limitaciones de la plataforma (memoria justa, doble pantalla, formatos propietarios) obligan a escribir código limpio y eficiente, y la verdad es que me lo he pasado en grande con estos retos.

Las 6 reglas de oro del desarrollo homebrew en 3DS

  1. Separa la lógica del juego del render. La lógica portable en un archivo independiente, el render de 3DS en otro. Es la estrategia que permitió desarrollar 2048-3DS de forma eficiente.
  2. C2D_TargetClear antes de cada escena. Siempre. Sin excepciones. Si no, artefactos de VRAM.
  3. linearAlloc para el audio, nunca malloc. Y no olvides jamás DSP_FlushDataCache().
  4. Solo WAV PCM para todos los archivos de audio. Convierte tus MP3/OGG antes de integrarlos.
  5. Prueba el .cia además del .3dsx. Los permisos son distintos, y olvidar un servicio en el RSF provoca un crash.
  6. Declara todos los servicios de sistema en el archivo RSF, en especial dsp::DSP, csnd:SND, hid:USER y fs:USER.

El proyecto 2048 para Nintendo 3DS pone en práctica todos los conceptos de este tutorial: render en doble pantalla, animaciones fluidas, audio multipista, guardado binario, localización en 13 idiomas, sistema de logros y empaquetado en .3dsx y .cia. Lo he publicado en open source para que sirva de base a tus propios proyectos.

Descargar 2048 para Nintendo 3DS, disponible gratis en .3dsx y .cia. Echa un vistazo al código fuente para ver todos estos conceptos en acción. Y si te lanzas a un proyecto homebrew de 3DS, compártelo: la comunidad es acogedora y siempre está dispuesta a echar una mano.

Para comparar con un enfoque sin escribir código, nuestro artículo Crear un juego con ChatGPT muestra a Codex, la IA de programación de OpenAI, construyendo un pequeño RPG retro al estilo de EarthBound, jugable en el navegador.

Recursos para el desarrollo homebrew en 3DS

  • devkitPro: https://devkitpro.org/, Toolchain oficial para el desarrollo homebrew en 3DS
  • Documentación de libctru: https://libctru.devkitpro.org/, Referencia completa de la API de libctru
  • Headers de citro2d: los archivos de cabecera de $DEVKITARM/../libctru/include/citro2d/ son la mejor documentación de la biblioteca gráfica
  • Wiki 3dbrew: https://www.3dbrew.org/, Wiki técnica exhaustiva de la Nintendo 3DS (hardware, formatos, servicios de sistema)
  • Ejemplos oficiales: https://github.com/devkitPro/3ds-examples, Ejemplos de gráficos, audio y red
  • bannertool: https://github.com/Steveice10/bannertool, Generador de banners e iconos para el formato CIA
  • makerom: https://github.com/3DSGuy/Project_CTR, Ensamblador de archivos CIA/CCI para Nintendo 3DS

Especificaciones técnicas de la Nintendo 3DS

  • CPU: ARM11 MPCore (ARMv6K) @ 268 MHz, 2 núcleos (4 en New 3DS)
  • GPU: DMP PICA200 @ 268 MHz
  • RAM: 128 MB (256 MB en New 3DS), de los que 6 MB son VRAM dedicada
  • Pantalla superior: 400×240 píxeles (800×240 en modo 3D estereoscópico)
  • Pantalla inferior: 320×240 píxeles, táctil resistiva
  • Audio: 24 canales DSP, salida estéreo
Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.