Homebrew para Nintendo 3DS: cria o teu próprio jogo

Homebrew para Nintendo 3DS: cria o teu próprio jogo

Queres criar o teu próprio jogo homebrew para a Nintendo 3DS? Boas notícias: ainda é perfeitamente possível e é um projeto muito divertido. Neste tutorial partilho tudo o que aprendi a desenvolver o 2048 para Nintendo 3DS, um jogo homebrew completo, com animações, áudio, 13 idiomas e um sistema de conquistas, da primeira linha de código até à geração de um ficheiro .cia instalável.

Vê o resultado final: 2048 para Nintendo 3DS, disponível para descarregar gratuitamente em .3dsx e .cia.

Este guia cobre o pipeline completo do desenvolvimento homebrew para 3DS: gráficos 2D com citro2d, áudio NDSP, ecrã tátil, sistema de ficheiros RomFS e empacotamento em CIA. Todo o código está em C99. Sem teoria a mais, apenas código que corre, tirado diretamente do projeto 2048-3DS.

Índice

  1. O que é um homebrew 3DS
  2. Pré-requisitos: instalar o devkitARM e as ferramentas de desenvolvimento 3DS
  3. Arquitetura de um projeto homebrew: separar lógica e renderização
  4. Gerir o duplo ecrã da Nintendo 3DS
  5. Renderização 2D com citro2d
  6. Gestão das entradas: D-pad, circle pad e ecrã tátil
  7. Áudio homebrew 3DS: NDSP e formato WAV PCM
  8. Sistema de ficheiros: RomFS e cartão SD
  9. Guardar dados no cartão SD
  10. O Makefile 3DS: cross-compilation ARM com o devkitARM
  11. Gerar um ficheiro .3dsx para o Homebrew Launcher
  12. Construir um ficheiro .cia instalável para o menu HOME
  13. Armadilhas e erros frequentes no desenvolvimento homebrew 3DS
  14. Conclusão e recursos para ir mais longe

1. O que é um homebrew da Nintendo 3DS?

Um homebrew 3DS é uma aplicação não oficial executada numa consola Nintendo 3DS equipada com um custom firmware (CFW) como o Luma3DS. A cena homebrew da 3DS permite criar e distribuir livremente jogos, emuladores e utilitários para esta consola portátil.

A Nintendo 3DS tem um processador ARM11 (ARMv6K) a 268 MHz e uma GPU PICA200, com uma particularidade única: dois ecrãs. O ecrã superior mostra 400×240 píxeis (800×240 em 3D estereoscópico) e o ecrã inferior, tátil, mostra 320×240 píxeis.

Existem dois formatos de distribuição para os homebrews 3DS:

  • .3dsx: formato homebrew lançado através do Homebrew Launcher. Sem instalação, execução direta a partir do cartão SD.
  • .cia: formato instalável que aparece no menu HOME da 3DS, com ícone e banner animado. Exige um custom firmware (CFW).

Para o 2048-3DS, gero os dois formatos para chegar ao máximo de jogadores. A comunidade homebrew da 3DS continua bem viva graças a um ecossistema de ferramentas sólidas: devkitARM (toolchain), libctru (biblioteca de sistema), citro2d/citro3d (gráficos). Se procuras um bom campo de treino para aprender programação de sistemas embebidos ou desenvolvimento de jogos retro, a 3DS é perfeita para isso.

2. Pré-requisitos: instalar o devkitARM e as ferramentas de desenvolvimento 3DS

Antes de começar a programar um jogo para a Nintendo 3DS, é preciso instalar a toolchain de cross-compilation ARM e as bibliotecas próprias da consola.

Toolchain devkitARM: o compilador para a Nintendo 3DS

O devkitARM é a toolchain GCC de cross-compilation para processadores ARM, fornecida pelo projeto devkitPro. Inclui o compilador, o linker e as ferramentas de build necessárias para compilar código C para a 3DS. Instala-se assim:

bash
# Instalação em Linux/WSL (recomendado para o desenvolvimento 3DS)
wget https://apt.devkitpro.org/install-devkitpro-pacman
chmod +x install-devkitpro-pacman
sudo ./install-devkitpro-pacman

# Instalar os pacotes de desenvolvimento 3DS
sudo dkp-pacman -S 3ds-dev

# Variável de ambiente obrigatória
export DEVKITARM=/opt/devkitpro/devkitARM

Bibliotecas essenciais para o desenvolvimento homebrew 3DS

  • libctru: biblioteca C de baixo nível para os serviços de sistema da 3DS (HID, filesystem, áudio, GPU). É a base de qualquer programa homebrew 3DS.
  • citro3d: camada de abstração sobre a GPU PICA200. Trata dos render targets, do framebuffer e da sincronização gráfica.
  • citro2d: camada 2D construída sobre o citro3d. Fornece primitivas simples (retângulos, círculos, elipses, linhas, texto, sprites) ideais para jogos 2D na 3DS.

Ferramentas complementares para o empacotamento

  • bannertool: gera os ficheiros banner.bin e icon.bin necessários ao formato CIA
  • makerom: monta o ficheiro .cia final a partir do ELF, do RSF, do banner e do ícone
  • tex3ds: converte imagens PNG em texturas .t3x legíveis pelo citro2d na GPU PICA200
  • mkbcfnt: converte fontes TTF para o formato .bcfnt usado na renderização de texto na 3DS (suporta Unicode, CJK, cirílico)

3. Arquitetura de um projeto homebrew 3DS: separar lógica e renderização

A armadilha clássica de quem começa no desenvolvimento homebrew é escrever todo o código diretamente contra as API da consola. Resultado: torna-se impossível testar ou depurar como deve ser. Já passei por isso, e o conselho que dou a toda a gente é separar a lógica de jogo da renderização gráfica desde o início.

É exatamente a estratégia usada no 2048-3DS: a lógica do jogo 2048 (grelha, movimentos, fusões, pontuação) é 100% portável, enquanto a renderização é específica de cada plataforma.

Estrutura de ficheiros do projeto 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 portável: a lógica do jogo 2048 sem dependências

O ficheiro logic.c do 2048-3DS não inclui nenhum header específico da Nintendo 3DS. Usa apenas os headers C padrão (<stdint.h>, <stdlib.h>, <string.h>, <time.h>). O ficheiro main_3ds.c chama as funções públicas da lógica:

c
// logic.h — interface portável do jogo 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;   // posição antes do movimento
    int to_r, to_c;       // posição depois do movimento
    uint16_t value;       // valor mostrado durante a animação
    int merged;           // 1 se houve fusão
} TileAnim;

typedef struct {
    uint16_t cells[GRID_SIZE][GRID_SIZE];  // grelha 4x4
    uint32_t score;
    int won, over;
    TileAnim anims[MAX_TILE_ANIMS];  // dados de animação da última jogada
    int anim_count;
    int spawn_r, spawn_c;     // posição da nova peça
    uint16_t spawn_val;        // valor (2 a 90%, 4 a 10%)
} Game;

void game_init(Game *g);                    // grelha vazia + 2 peças
int  game_move(Game *g, Direction dir);     // devolve 1 se a grelha mudou
int  game_is_over(Game *g);                 // nenhum movimento possível
int  game_has_won(Game *g);                 // peça >= 2048 atingida

O Makefile 3DS compila logic.c + achievements.c + main_3ds.c e filtra explicitamente o main_sdl.c. A lógica de jogo mantém-se idêntica seja qual for o front-end de renderização.

4. Gerir o duplo ecrã da Nintendo 3DS

A particularidade que distingue a Nintendo 3DS de qualquer outra consola portátil é o seu duplo ecrã. Aproveitá-lo bem é essencial para uma boa experiência homebrew.

  • Ecrã superior (top): 400×240 píxeis, sem toque. Ideal para o gameplay principal. No 2048-3DS é aqui que mostro a grelha 4×4 com as animações das peças.
  • Ecrã inferior (bottom): 320×240 píxeis, tátil. Ideal para a interface, os menus e os controlos. No 2048-3DS mostro aqui a pontuação, os botões, as definições e as conquistas.

Inicializar os render targets com o citro2d

c
// Inicialização do sistema gráfico da 3DS
gfxInitDefault();
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
C2D_Prepare();

// Criar um render target para cada ecrã
C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);

Ciclo de renderização em duplo ecrã

Em cada frame é preciso desenhar nos dois ecrãs separadamente. Esta é a estrutura do ciclo de renderização usado no 2048-3DS:

c
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);

// Ecrã superior — grelha do jogo 2048
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_top_screen(&game, anim_phase, progress);

// Ecrã inferior — UI (pontuação, botões, definições)
C2D_TargetClear(bot, col_bot_bg);
C2D_SceneBegin(bot);
render_bot_game(&game, best_score, 0, music_muted);

C3D_FrameEnd(0);

Armadilha crítica da renderização na 3DS: o C2D_TargetClear é obrigatório. Se não chamares C2D_TargetClear() antes de cada cena, vais ter artefactos visuais aleatórios, resíduos da VRAM da frame anterior. A VRAM da 3DS não é inicializada automaticamente. É um bug extremamente desconcertante para quem começa no homebrew, porque os artefactos mudam a cada frame.

Dimensões dos ecrãs: constantes essenciais

c
#define TOP_W  400   // largura do ecrã superior
#define TOP_H  240   // altura do ecrã superior
#define BOT_W  320   // largura do ecrã inferior (tátil)
#define BOT_H  240   // altura do ecrã inferior

Atenção: na consola física, o ecrã de baixo está centrado horizontalmente por baixo do ecrã de cima (400 contra 320 píxeis de largura). As coordenadas do toque correspondem diretamente aos píxeis do ecrã inferior, com (0,0) no canto superior esquerdo.

5. Renderização 2D com citro2d na Nintendo 3DS

O citro2d é a biblioteca de renderização 2D de referência para os jogos homebrew da 3DS. Construída sobre o citro3d e a GPU PICA200, trata internamente do batching das primitivas e da gestão das texturas na GPU. Vamos ver como se usa, com exemplos concretos tirados do 2048-3DS.

Desenhar primitivas: retângulos, círculos, elipses e linhas

c
// Retângulo cheio (peças do jogo 2048)
C2D_DrawRectSolid(x, y, z, largeur, hauteur, couleur);

// Círculo cheio (cantos arredondados, ícones)
C2D_DrawCircleSolid(centre_x, centre_y, z, rayon, couleur);

// Elipse cheia (ícone de música no 2048-3DS)
C2D_DrawEllipseSolid(x, y, z, largeur, hauteur, couleur);

// Linha (barra de mute do áudio)
C2D_DrawLine(x1, y1, couleur1, x2, y2, couleur2, epaisseur, z);

O parâmetro z controla a profundidade (0.0f para o plano padrão). As cores na 3DS usam o formato ABGR em memória (atenção, é o inverso do ARGB habitual). O citro2d fornece a macro C2D_Color32(r, g, b, a) para construir as cores corretamente:

c
// Armadilha do C99: C2D_Color32() não é constexpr
// Usar uma macro para as constantes de cor globais
#define MAKE_COLOR(r,g,b,a) \
    ((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))

// Paleta de cores do jogo 2048 na 3DS
#define col_grid_bg  MAKE_COLOR(0xBB, 0xAD, 0xA0, 0xFF)  // fundo da grelha
#define col_bot_bg   MAKE_COLOR(0xFA, 0xF8, 0xEF, 0xFF)  // fundo do ecrã inferior
#define col_text_dk  MAKE_COLOR(0x77, 0x6E, 0x65, 0xFF)  // texto escuro
#define col_text_lt  MAKE_COLOR(0xF9, 0xF6, 0xF2, 0xFF)  // texto claro

Retângulos arredondados: um truque gráfico para o citro2d

O citro2d não fornece uma primitiva «retângulo arredondado». Para as peças do jogo 2048 e para os botões da interface, criei esta técnica combinando um retângulo central, dois retângulos laterais e quatro círculos nos cantos:

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;
    }
    // Corpo central + bordos laterais
    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 raio r nos cantos
    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 função é usada em todo o 2048-3DS: peças do jogo, caixas de pontuação, botões, sliders de volume e caixas de diálogo de confirmação.

Mostrar texto na Nintendo 3DS com o formato .bcfnt

A renderização de texto na 3DS passa pelo formato .bcfnt (Binary CTR Font). Cada string tem de ser analisada para um buffer de texto antes de ser desenhada. No 2048-3DS, esta técnica é usada para mostrar as pontuações, os nomes dos botões em 13 idiomas e as notificações de conquistas:

c
// Inicialização dos buffers de texto
C2D_TextBuf s_dynamicBuf = C2D_TextBufNew(512);
C2D_Font s_font = C2D_FontLoad("romfs:/font.bcfnt");

// Função utilitária: desenhar 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: limpar o buffer de texto em cada frame
C2D_TextBufClear(s_dynamicBuf);

O fator 0.62f foi afinado para obter um centrado vertical visualmente correto do texto.

Sprites e texturas GPU no formato .t3x

As imagens têm de ser convertidas para o formato .t3x (textura 3DS otimizada para a GPU PICA200) com a ferramenta tex3ds. Um ficheiro .t3s descreve o sprite sheet. No 2048-3DS, os 8 ícones de conquistas (32×32 píxeis cada) estão empacotados num ú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
// Carregamento do sprite sheet a partir do RomFS
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");

// Mostrar um ícone de conquista
C2D_Image img = C2D_SpriteSheetGetImage(sheet, index);
C2D_DrawImageAt(img, x, y, 0.0f, NULL, scale_x, scale_y);

// Mostrar em greyscale (conquista bloqueada)
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);

Animações fluidas na Nintendo 3DS

Para animações fluidas num jogo homebrew da 3DS, usa o osGetTime() (tempo em milissegundos) e interpolação com easing. Este é o sistema de animação implementado no 2048-3DS para o deslizamento e o aparecimento das peças:

c
// Tempos das animações do jogo 2048
#define ANIM_SLIDE_MS  120    // duração do deslizamento da peça
#define ANIM_POP_MS    100    // duração do "pop" (fusão/aparecimento)

typedef enum { ANIM_NONE, ANIM_SLIDING, ANIM_POPPING } AnimPhase;

// Easing quadrático: desaceleração natural
static float ease_out_quad(float t) {
    return t * (2.0f - t);
}

// No ciclo principal: interpolação das posições
u64 now = osGetTime();
u64 elapsed = now - anim_start;
float progress = (float)elapsed / ANIM_SLIDE_MS;
float t = ease_out_quad(progress);

// Deslizamento linear da peça
float cur_x = from_px + (to_px - from_px) * t;
float cur_y = from_py + (to_py - from_py) * t;

A animação corre em duas fases: primeiro o deslizamento (120 ms), depois o «pop», as peças fundidas crescem até 125% antes de voltarem a 100%, e a peça nova aparece a crescer de 0 a 100%. É este sistema que torna o jogo 2048 na 3DS tão satisfatório visualmente como a versão original.

6. Gestão das entradas na Nintendo 3DS: D-pad, circle pad e ecrã tátil

A Nintendo 3DS oferece vários periféricos de entrada para os jogos homebrew, todos acessíveis através da biblioteca libctru (HID). No 2048-3DS uso os três: D-pad e circle pad para mover as peças, ecrã tátil para os botões e os sliders.

Ler os botões físicos do D-pad e as teclas A/B/X/Y

c
hidScanInput();            // Ler o estado das entradas (1x por frame)
u32 kDown = hidKeysDown(); // Botões premidos nesta frame

// D-pad: mover as peças no jogo 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; }

// Botões de sistema
if (kDown & KEY_B)      { /* navegar para trás */ }
if (kDown & KEY_START)  { break; /* sair da aplicação */ }
if (kDown & KEY_SELECT) { game_init(&game); /* novo jogo */ }

Circle Pad: o stick analógico da 3DS com deadzone

c
circlePosition cpad;
hidCircleRead(&cpad);

// Deadzone de 80 para evitar falsos positivos
// Gama do circle pad: cerca de -155 a +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; }

A deadzone é crítica no circle pad da 3DS. Sem um limiar, o stick regista micromovimentos em contínuo e dispara movimentos indesejados. Um valor de 80 (numa gama de cerca de -155 a +155) funciona bem na prática para um jogo como o 2048.

Ecrã tátil: tratar os toques nos botões da interface

O ecrã tátil da 3DS é resistivo (pressão, não capacitivo). As coordenadas vêm diretamente em píxeis do ecrã inferior (320×240). No 2048-3DS, o ecrã tátil trata dos botões «Novo jogo», «Conquistas» e «Definições», dos sliders de volume e da escolha do idioma:

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

// Deteção de toque no botão "Novo jogo"
    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 o hidKeysDown() com KEY_TOUCH para os toques pontuais (botões) e o hidKeysHeld() para as interações contínuas (sliders de volume, arrastar).

Ciclo principal de um jogo homebrew 3DS

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

hidScanInput();
    u32 kDown = hidKeysDown();

if (kDown & KEY_START) break;  // Saída limpa

// Tratamento das entradas (D-pad, circle pad, toque)
    // Atualizar a lógica do jogo 2048
    // Renderizar os dois ecrãs
    // ...
}

O aptMainLoop() trata do ciclo de vida da aplicação homebrew: suspensão, fecho pelo sistema, regresso ao menu HOME.

7. Áudio homebrew 3DS: NDSP e formato WAV PCM

Acredita na minha experiência: o áudio na Nintendo 3DS foi onde perdi mais tempo no desenvolvimento homebrew. No papel parece tudo simples, mas as armadilhas estão em todo o lado. O backend de áudio da 3DS é o NDSP (Nintendo DSP): precisa do ficheiro de firmware do DSP (dspfirm.bin no cartão SD) e suporta mistura multicanal com interpolação linear.

No 2048-3DS implementei a música de fundo em 4 faixas e os efeitos sonoros dos movimentos das peças e das conquistas.

Formato áudio para a 3DS: WAV PCM obrigatório

Grande armadilha do áudio na 3DS: a consola só suporta ficheiros WAV PCM em bruto (nada de MP3, nada de OGG, nada de WAV comprimido em ADPCM). Os ficheiros têm de estar em PCM de 8 ou 16 bits, mono ou estéreo. Qualquer outro formato é ignorado em silêncio ou provoca um crash.

Alocação de memória para o áudio na 3DS: linearAlloc em vez de malloc

Os buffers de áudio na 3DS têm de ser alocados com linearAlloc() e não com malloc(). A memória linear é diretamente acessível ao processador DSP, a heap normal não é.

c
// Estrutura para guardar um ficheiro áudio WAV na 3DS
typedef struct {
    u8  *data;            // Dados PCM (alocados com linearAlloc)
    u32  size;            // Tamanho em bytes
    u32  sample_rate;     // Frequência de amostragem
    u16  channels;        // 1 (mono) ou 2 (estéreo)
    u16  bits_per_sample; // 8 ou 16 bits
    ndspWaveBuf wave_buf; // Buffer NDSP
    int  loaded;          // Flag de carregamento bem sucedido
} WavSound;

Carregar ficheiros WAV num homebrew 3DS

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

// Ler e validar o header 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;
    }

// Percorrer os 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) {
            // Extrair channels, sample_rate, bits_per_sample
            // ...
        } else if (memcmp(chunk_id, "data", 4) == 0) {
            // IMPORTANTE: linearAlloc, não malloc!
            snd->data = (u8 *)linearAlloc(chunk_size);
            fread(snd->data, 1, chunk_size, f);
        } else {
            fseek(f, chunk_size, SEEK_CUR);
        }
    }

// Preparar o 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;

// OBRIGATÓRIO: fazer flush da cache do CPU para o DSP
    DSP_FlushDataCache(snd->data, snd->size);
    return 0;
}

O DSP_FlushDataCache é obrigatório depois de cada escrita num buffer de áudio da 3DS. Sem esta chamada, o DSP lê dados corrompidos porque a cache do CPU não está coerente com a memória do DSP. É a causa n.º 1 de bugs de áudio silenciosos na Nintendo 3DS.

Inicialização do áudio NDSP

Esta é a inicialização de áudio implementada no 2048-3DS:

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

ndspSetOutputMode(NDSP_OUTPUT_STEREO);

// Canal 0: música de fundo
    // Canal 1: SFX (movimento de peça)
    // Canal 2: SFX (conquista desbloqueada)
    for (int ch = 0; ch < 3; ch++) {
        ndspChnReset(ch);
        ndspChnSetInterp(ch, NDSP_INTERP_LINEAR);
        ndspChnSetFormat(ch, NDSP_FORMAT_STEREO_PCM16);
    }
}

Tocar um som e gerir o volume na 3DS

c
// NDSP: acrescentar o buffer áudio ao canal
snd->wave_buf.status = NDSP_WBUF_FREE;  // reinicializar o status
DSP_FlushDataCache(snd->data, snd->size);
ndspChnWaveBufAdd(channel, &snd->wave_buf);

// Volume NDSP: array de 12 floats (mix 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 esquerdo
mix[1] = vol;  // canal direito
ndspChnSetMix(0, mix);

Encadeamento automático das faixas de música

No 2048-3DS, 4 faixas de música encadeiam-se automaticamente (intro, music1, music2, music3). Em cada frame verifico se a faixa em curso já terminou:

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

if (!ndspChnIsPlaying(0)) {
        // Passar à faixa seguinte (ciclo)
        s_music_current = (s_music_current + 1) % MUSIC_TRACK_COUNT;
        audio_play_current_track(music_volume, music_muted);
    }
}

Limpeza do áudio no fim do programa

c
// Libertar corretamente os recursos áudio
ndspChnReset(0);
ndspChnReset(1);
ndspChnReset(2);
ndspExit();

// Libertar a memória linear (não free, mas 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 ficheiros da 3DS: RomFS e cartão SD

A Nintendo 3DS oferece dois sistemas de ficheiros às aplicações homebrew: o RomFS para os assets só de leitura e o SDMC para leitura/escrita no cartão SD.

RomFS: embutir os assets no binário homebrew

O RomFS (Read-Only Memory FileSystem) permite embutir ficheiros diretamente no binário .3dsx ou .cia. É ideal para os assets que não mudam: fontes, texturas, músicas, sprites. No 2048-3DS, a pasta romfs/ contém a fonte .bcfnt com suporte CJK, o sprite sheet das conquistas e 6 ficheiros áudio WAV:

c
// Inicialização obrigatória antes de qualquer acesso ao RomFS
romfsInit();

// Acesso aos assets com o prefixo 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");

O conteúdo da pasta romfs/ é embutido automaticamente pelo Makefile 3DS:

bash
# No Makefile.3ds
ROMFS := romfs

Formatos de assets específicos da Nintendo 3DS

  • .bcfnt: fonte bitmap compilada com o mkbcfnt. Suporta Unicode completo (CJK, cirílico, caracteres acentuados). No 2048-3DS, uma única fonte cobre os 13 idiomas.
  • .t3x: textura GPU compilada com o tex3ds. Formato otimizado para a PICA200. Carrega diretamente para a VRAM, sem conversão.
  • .wav: áudio PCM em bruto. Sem conversão em runtime, mas tem de estar em PCM não comprimido (ver a secção sobre áudio).

SDMC: guardar dados no cartão SD da 3DS

Para guardar dados (pontuações, definições, progresso), os homebrews da 3DS escrevem no cartão SD com o prefixo sdmc:/:

c
// Caminhos de gravação do 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"

// Criar a pasta de gravação no arranque
#include <sys/stat.h>
mkdir("sdmc:/3ds", 0777);
mkdir(SAVE_DIR, 0777);

A escrita usa as funções C padrão (fopen, fwrite, fclose). Não é preciso nenhuma API específica da 3DS para as operações de ficheiros em SDMC.

9. Guardar dados no cartão SD num homebrew 3DS

Num jogo homebrew da 3DS, a gravação binária é o método mais direto e mais económico. Vejamos como o 2048-3DS implementa a persistência das conquistas e das definições.

Gravação binária do sistema de conquistas

O jogo 2048-3DS tem 8 conquistas (patamares de pontuação: 500, 1000, 2500, 5000, 10000, 20000, 50000, 100000 pontos). A gravação ocupa 8 bytes, um byte por conquista:

c
// Gravação: 8 bytes (0x00 = bloqueada, 0x01 = desbloqueada)
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;
}

// Carregamento com valores por omissão se o ficheiro não existir
int achievements_load(Achievements *a, const char *path)
{
    achievements_init(a);  // todas bloqueadas por omissão
    FILE *f = fopen(path, "rb");
    if (!f) return -1;     // primeira execução: sem ficheiro
    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;
}

Gravação das definições do utilizador

As definições do 2048-3DS (idioma, volume da música, volume dos efeitos, estado do mute) são gravadas em 4 bytes:

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

Vantagens desta abordagem num jogo homebrew: tamanho fixo, sem parsing, sem dependência de uma biblioteca JSON/XML, leitura instantânea. As definições são gravadas automaticamente quando o utilizador sai do menu de definições ou da aplicação.

10. O Makefile 3DS: cross-compilation ARM com o devkitARM

O sistema de build dos homebrews da 3DS assenta nas regras fornecidas pelo devkitARM. Perceber o Makefile 3DS é essencial para depurar os problemas de compilação.

Estrutura do Makefile de um projeto homebrew 3DS

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

include $(DEVKITARM)/3ds_rules

# Configuração do projeto homebrew
TARGET   := 2048
BUILD    := build_3ds
SOURCES  := source
ROMFS    := romfs

# Metadados mostrados no Homebrew Launcher e no menu HOME
APP_TITLE       := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR      := Gekkode

Flags de compilação ARM para a 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__

Explicação de cada flag da cross-compilation ARM para a 3DS:

  • -march=armv6k: arquitetura do processador ARM11 da Nintendo 3DS
  • -mtune=mpcore: otimiza o código para o core MPCore
  • -mfloat-abi=hard: usa o VFP (vírgula flutuante por hardware), crucial para o desempenho gráfico
  • -mword-relocations: gera as relocations necessárias ao formato 3DSX
  • -ffunction-sections: permite ao linker eliminar o código morto e reduzir o tamanho do binário
  • -D__3DS__: define a macro para os #ifdef condicionais entre PC e 3DS

Linking das bibliotecas citro2d e libctru

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

A ordem das bibliotecas é importante para o linker GNU: -lcitro2d depende de -lcitro3d, que depende de -lctru. Ordena sempre da abstração mais alta para a mais baixa.

Filtrar as fontes: excluir a renderização PC do build 3DS

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

Esta filtragem garante que só o main_3ds.c é compilado no build para a 3DS.

Comandos de build

bash
# Compilar o homebrew em .3dsx
make -f Makefile.3ds

# Compilar e gerar um .cia instalável
make -f Makefile.3ds cia

# Limpeza completa
make -f Makefile.3ds clean

11. Gerar um ficheiro .3dsx para o Homebrew Launcher

O formato .3dsx é o formato homebrew padrão da Nintendo 3DS, lançado através do Homebrew Launcher. É o mais simples de distribuir: um único ficheiro para copiar para o cartão SD.

O Makefile 3DS gera automaticamente dois ficheiros:

  • 2048.3dsx: o executável homebrew, com o RomFS embutido
  • 2048.smdh: os metadados SMDH (título, autor, descrição, ícone)

SMDH: os metadados do teu homebrew 3DS

O ficheiro SMDH (Simple Metadata Header) contém as informações mostradas no Homebrew Launcher:

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

O ícone é um PNG de 48×48 píxeis, detetado automaticamente se existir um icon.png ou um 2048.png na raiz do projeto.

Embutir o RomFS no .3dsx

O RomFS é integrado diretamente no ficheiro .3dsx. O utilizador final só tem de lidar com um ficheiro:

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

12. Construir um ficheiro .cia instalável para o menu HOME da 3DS

O formato .cia (CTR Importable Archive) é o formato instalável da Nintendo 3DS. Depois de instalado através do FBI ou de outro gestor de títulos, o jogo aparece no menu HOME da consola com um ícone personalizado, um banner animado e um som de arranque.

É o formato usado para distribuir o 2048 para Nintendo 3DS em versão .cia.

Atenção: esta é de longe a etapa mais delicada do desenvolvimento homebrew para 3DS. Passei imenso tempo a perceber porque é que um ficheiro .cia podia correr na perfeição no emulador Citra e rebentar de imediato numa 3DS a sério. A causa é quase sempre um ficheiro RSF mal configurado. O que se segue é o resultado de muitas horas de testes em hardware real — detalho cada parâmetro para que não tenhas de passar pelo mesmo.

Assets necessários para construir um .cia

  • Ícone: PNG de 48×48 píxeis exatos (mostrado no menu HOME)
  • Imagem do banner: PNG de 256×128 píxeis exatos (mostrada no topo do ecrã quando o jogo está selecionado)
  • Som do banner: WAV PCM 16 bits, 44100 Hz, estéreo, cerca de 3 segundos (tocado quando o jogo é selecionado no menu HOME). O formato é rígido, usa o ffmpeg para converter:
bash
# Converter qualquer áudio em WAV compatível com o bannertool
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wav

Etapas da construção de um ficheiro CIA para a 3DS

A construção de um .cia faz-se em 4 etapas. A abordagem recomendada é usar um template RSF com variáveis $(VARIABLE) substituídas pelas flags -DVARIABLE="valeur" do makerom. Este método permite separar a configuração (no Makefile) do template de permissões (no RSF), o que torna o build mais limpo e reutilizável:

bash
# 1. Construir o binário ELF
make -f Makefile.3ds

# 2. Gerar o banner (imagem + áudio de arranque)
bannertool makebanner \
    -i assets/banner.png \
    -a assets/music/banner.wav \
    -o build_3ds/banner.bnr

# 3. Gerar o ícone SMDH para o menu HOME
bannertool makesmdh \
    -s "2048" \
    -l "2048 - puzzle game for 3DS" \
    -p "Gekkode" \
    -i icon.png \
    -o build_3ds/icon.icn

# 4. Montar o .cia final com o 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" substitui a variável $(XXX) correspondente no ficheiro RSF. Este sistema de substituição permite ter um único template RSF reutilizável para todos os teus projetos, só as flags -D mudam de um projeto para o outro.

As flags importantes do makerom:

  • -target t: alvo «test» (para os homebrews não assinados pela Nintendo)
  • -exefslogo: inclui o logótipo no ExeFS (necessário ao splash screen de arranque)
  • -major / -minor / -micro: versão do título (mostrada nas definições do sistema)

O ficheiro RSF: configurar as permissões do teu homebrew CIA

O ficheiro RSF (ROM Specification File) é o ficheiro mais crítico do build CIA. Define os metadados, as permissões de sistema, os serviços autorizados e as chamadas de sistema disponíveis. Um RSF incompleto = crash imediato em hardware real, mesmo que tudo funcione no Citra.

Este é o template RSF completo usado no 2048-3DS, afinado ao longo de muitos testes em hardware real. Cada secção é explicada a seguir:

BasicInfo: identidade do título

yaml
BasicInfo:
  Title                   : $(APP_TITLE)
  ProductCode             : $(APP_PRODUCT_CODE)
  Logo                    : Nintendo
  • Title: o nome mostrado nas definições do sistema. Usa uma variável $(APP_TITLE) substituída por -DAPP_TITLE="2048".
  • ProductCode: um identificador no formato CTR-H-XXXX. O H significa homebrew. Escolhe um código único (por exemplo CTR-H-2048).
  • Logo: o splash screen animado mostrado no arranque.
    • Nintendo: o logótipo animado oficial da 3DS (recomendado, é o comportamento padrão)
    • Homebrew: o logótipo da comunidade homebrew
    • Licensed / Distributed: variantes do logótipo Nintendo
    • None: a evitar, pode provocar crashes em certas versões do firmware

RomFs: recursos embutidos

c
RomFs:
  RootPath: $(APP_ROMFS)

Caminho para a pasta romfs/ com os assets embutidos (sprites, fontes, áudio). Passado através de -DAPP_ROMFS="romfs". Se a tua aplicação não tiver RomFS, podes omitir esta secção.

TitleInfo: identificação única

yaml
TitleInfo:
  Category                : $(APP_CATEGORY)
  UniqueId                : $(APP_UNIQUE_ID)
  • Category: sempre Application para um jogo ou utilitário homebrew.
  • UniqueId: identificador hexadecimal único no intervalo 0xF0000 a 0xFFFFF (gama reservada aos homebrews). Cada homebrew instalado na consola tem de ter um UniqueId diferente, para evitar conflitos. No 2048 uso 0xF2048.

Option: opções de empacotamento

c
Option:
  UseOnSD                 : $(APP_USE_ON_SD)
  FreeProductCode         : true
  MediaFootPadding        : false
  EnableCrypt             : $(APP_ENCRYPTED)
  EnableCompress          : true
  • UseOnSD: true (obrigatório num homebrew instalado no cartão SD).
  • FreeProductCode: true para que o makerom aceite um ProductCode livre (sem validação do formato Nintendo).
  • EnableCrypt: false (os homebrews não têm chaves de cifra da Nintendo).
  • EnableCompress: true para comprimir a secção .code do ExeFS (reduz o tamanho do .cia).

AccessControlInfo: a secção crítica

É aqui que se jogam 90% dos crashes de um CIA. Esta secção define tudo o que a tua aplicação pode fazer na consola. Se faltar uma única chamada de sistema ou um único serviço, o CIA rebenta instantaneamente em hardware real — ao passo que o Citra ignora estas restrições.

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

Os parâmetros a adaptar ao teu projeto:

  • MemoryType: Application para um jogo ou utilitário normal. System para um módulo de sistema.
  • SystemMode: quantidade de RAM alocada. 64MB é o valor padrão. Usa 96MB apenas em aplicações exclusivas da New 3DS que precisem de mais memória.
  • SystemModeExt: Legacy para compatibilidade com Old 3DS e New 3DS.
  • CpuSpeed: 268MHz ativa o boost de frequência na New 3DS (na Old 3DS volta a 268 MHz, e isso é tratado automaticamente).
  • EnableL2Cache: false por omissão. Passa para true em aplicações intensivas em CPU na New 3DS (pode causar instabilidade na Old 3DS).

Seguem-se as quatro subsecções de permissões. O meu conselho: inclui o conjunto completo. Um homebrew simples não usa todos estes SVCs e serviços, mas declarar a mais não traz qualquer problema de desempenho ou de segurança numa consola com CFW — em contrapartida, esquecer um só provoca um crash imediato:

FileSystemAccess: acesso ao sistema de ficheiros

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

Os mais importantes num homebrew simples: DirectSdmc + DirectSdmcWrite (leitura/escrita no SD) e Core. Mas é mais seguro incluir o conjunto completo.

IoAccessControl: acesso I/O de baixo nível

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

SystemCallAccess: chamadas de sistema ARM11 (SVCs)

Esta lista define que chamadas de sistema (supervisor calls) a tua aplicação pode usar. É a secção mais crítica: se faltar um SVC e a libctru o chamar, a consola rebenta imediatamente. Inclui todos os SVCs de 1 a 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: os números não são contínuos, há buracos (63-70, 89-95, 110-111, 116). É normal, são SVCs reservados ou não implementados pelo kernel da 3DS.

ServiceAccessControl: serviços de sistema

Os serviços são as API de alto nível da 3DS. Cada biblioteca da libctru usa um ou mais serviços. Se um serviço não estiver declarado aqui, a chamada a srvGetServiceHandle() falha e a inicialização da biblioteca correspondente rebenta:

yaml
  ServiceAccessControl:
   - APT:U          # Ciclo de vida da aplicação (aptMainLoop)
   - ac:u           # Configuração de rede
   - am:net         # Application Manager (instalação de títulos)
   - boss:U         # SpotPass
   - cam:u          # Câmara
   - cecd:u         # StreetPass
   - cfg:nor        # Configuração NOR
   - cfg:u          # Configuração do sistema (idioma, região)
   - csnd:SND       # Áudio CSND
   - dsp::DSP       # Áudio NDSP (backend principal)
   - frd:u          # Lista de amigos
   - fs:USER        # Sistema de ficheiros (SDMC, RomFS)
   - gsp::Gpu       # GPU PICA200 (gráficos)
   - gsp::Lcd       # Controlo do ecrã LCD
   - hid:USER       # Input (botões, pad, toque)
   - http:C         # Cliente HTTP
   - ir:rst         # Infravermelhos (C-stick da New 3DS)
   - ir:u           # Infravermelhos genérico
   - ir:USER        # Infravermelhos do utilizador
   - mic:u          # Microfone
   - mcu::HWC       # Microcontrolador de hardware
   - ndm:u          # Network Daemon Manager
   - news:s         # Notificações
   - nwm::EXT       # Network Manager estendido
   - nwm::UDS       # Network Manager UDS (wireless local)
   - ptm:sysm       # Power/Timer Manager do sistema
   - ptm:u          # Power/Timer Manager do utilizador
   - pxi:dev        # Acesso ao dispositivo PXI
   - soc:U          # Sockets (rede TCP/UDP)
   - ssl:C          # SSL/TLS
   - y2r:u          # Conversão YUV para RGB

Num jogo homebrew simples, os serviços indispensáveis são: APT:U, fs:USER, gsp::Gpu e hid:USER. Acrescenta dsp::DSP se usares áudio e cfg:u se leres a configuração do sistema. Mas, tal como nos SVCs, é mais seguro incluir o conjunto completo — não há qualquer penalização por declarar um serviço que não é usado.

SystemControlInfo: stack, gravação e dependências

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 se gravares diretamente no cartão SD (é o que faz a maioria dos homebrews). Indica um tamanho se usares a API de gravação do sistema.
  • StackSize: 0x40000 (256 KB) é um valor sólido por omissão. Aumenta-o se a tua aplicação fizer recursão pesada ou alocar arrays grandes na stack.
  • Dependency: os title IDs dos módulos de sistema de que a tua aplicação depende. Atenção: os title IDs NÃO podem ter o sufixo L. Escrever 0x0004013000002402L em vez de 0x0004013000002402 pode causar erros silenciosos. Copia a lista acima tal como está.

IORegisterMapping e MemoryMapping

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

Estas secções definem as gamas de memória e os registos I/O acessíveis. Copia os valores acima sem modificações, cobrem as necessidades de todos os homebrews normais.

Adaptar o RSF ao teu projeto

O template RSF acima é genérico e reutilizável. Para o adaptar a um novo projeto basta mudar as flags -D no comando makerom:

Flag -DValor no 2048A adaptar
APP_TITLE2048Nome da tua aplicação
APP_PRODUCT_CODECTR-H-2048CTR-H-XXXX único
APP_UNIQUE_ID0xF2048Hex único em 0xF0000-0xFFFFF
APP_ROMFSromfsCaminho da tua pasta romfs
APP_SYSTEM_MODE64MB96MB se for exclusivo da New 3DS
APP_VERSION_MAJOR1Número da versão principal

Os restantes valores (APP_ENCRYPTED=false, APP_CATEGORY=Application, APP_USE_ON_SD=true, etc.) são iguais para todos os homebrews.

Automatizar o build do CIA no Makefile

bash
# Variáveis no topo do 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

# Alvo CIA — basta um "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)"

Extensões .bnr e .icn: os ficheiros gerados pelo bannertool têm as extensões .bnr (banner) e .icn (icon). Alguns tutoriais usam .bin, mas as extensões corretas são .bnr e .icn, é o que fazem as buildtools de referência.

13. Armadilhas e erros frequentes no desenvolvimento homebrew 3DS

Bati com a cabeça em todas estas paredes durante o desenvolvimento do 2048-3DS, e teria adorado ter esta lista à mão desde o início. Aqui ficam os erros mais frustrantes e como evitá-los — vais poupar bastantes horas de debug ao criar o teu primeiro jogo homebrew para a Nintendo 3DS.

Artefactos na VRAM: o bug fantasma do ecrã da 3DS

Sintoma: aparecem píxeis coloridos aleatórios no ecrã, diferentes em cada frame.

Causa: esqueceste-te do C2D_TargetClear() antes do C2D_SceneBegin(). A VRAM da 3DS não é inicializada automaticamente, contém resíduos da frame anterior ou dados quaisquer.

c
// ERRADO — artefactos de VRAM garantidos
C2D_SceneBegin(top);
render_game();

// CORRETO — limpar sempre antes de desenhar
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_game();

linearAlloc contra malloc: a armadilha da memória de áudio na 3DS

Sintoma: não há som ou ouvem-se ruídos parasitas.

Causa: os buffers de áudio na 3DS têm de ser alocados com linearAlloc(). A memória devolvida por malloc() não é acessível ao processador DSP. Também é preciso usar linearFree() para libertar (e não free()).

c
// ERRADO — o DSP não consegue ler esta memória
u8 *audio_buf = malloc(size);

// CORRETO — memória linear acessível ao DSP
u8 *audio_buf = linearAlloc(size);
// ... utilização ...
linearFree(audio_buf);

DSP_FlushDataCache esquecido: áudio corrompido na 3DS

Sintoma: o som sai corrompido, dessincronizado ou completamente errado.

Causa: a cache do CPU e a memória do DSP da 3DS não são coerentes. É preciso fazer flush explícito da cache depois de escrever num buffer de áudio:

c
fread(snd->data, 1, chunk_size, f);
DSP_FlushDataCache(snd->data, snd->size);  // OBRIGATÓRIO!

Formato áudio incorreto: silêncio total

Sintoma: nenhum som, ou crash ao carregar.

Causa: o ficheiro WAV está comprimido (ADPCM, MP3 encapsulado, etc.) em vez de estar em PCM em bruto. A 3DS só suporta PCM não comprimido. Verifica e converte:

bash
# Verificar o codec do ficheiro WAV
ffprobe music.wav 2>&1 | grep "Audio:"
# Deve mostrar: pcm_s16le (16 bits) ou pcm_u8 (8 bits)

# Converter um MP3 em WAV PCM compatível com a 3DS
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 22050 music.wav

C2D_Color32 não é constexpr em C99

Sintoma: erro de compilação «initializer element is not constant» com o citro2d em C99.

Causa: o C2D_Color32() é uma função inline em C, não uma expressão constante. Não dá para a usar na inicialização de variáveis globais em C99. Solução:

c
// ERRADO em C99
static u32 my_color = C2D_Color32(0xFF, 0x00, 0x00, 0xFF);

// CORRETO — macro MAKE_COLOR em vez 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);

O .3dsx funciona mas o .cia rebenta: permissões no RSF

Sintoma: o homebrew corre perfeitamente em .3dsx através do Homebrew Launcher, mas rebenta de imediato em .cia (muitas vezes com a mensagem «ErrDisp: An error has occurred» ou «SD Card was removed»). No Citra, o .cia funciona.

Causa: o formato .3dsx herda as permissões alargadas do Homebrew Launcher. O formato .cia tem as suas próprias permissões, definidas no ficheiro RSF. O emulador Citra ignora as restrições de permissões, e é por isso que o .cia funciona em emulação mas não no hardware. Há três causas principais:

  • SystemCallAccess incompleto: se um SVC usado pela libctru não estiver declarado, o kernel ARM11 recusa a chamada e o processo é morto. Inclui todos os SVCs de 1 a 125.
  • ServiceAccessControl em falta: se um serviço (por exemplo dsp::DSP ou gsp::Gpu) não estiver declarado, o srvGetServiceHandle() falha e a inicialização da biblioteca correspondente rebenta. Inclui todos os serviços listados na secção 12.
  • Logo: None no RSF: em alguns firmwares, a ausência de logótipo no ExeFS provoca um crash no arranque. Usa Logo: Nintendo com a flag -exefslogo.

Solução: usa o template RSF completo apresentado na secção 12, com o conjunto completo de SVCs, serviços e permissões. É a forma mais fiável de garantir um .cia funcional em hardware real.

ndspWaveBuf não reinicializado: o som só toca uma vez

Sintoma: um efeito sonoro toca bem à primeira e depois nunca mais.

Causa: depois da reprodução, o status do ndspWaveBuf fica em «done». É preciso reinicializar o status e voltar a fazer flush da cache antes de tocar outra vez:

c
snd->wave_buf.status = NDSP_WBUF_FREE;    // reinicializar
DSP_FlushDataCache(snd->data, snd->size);  // voltar a fazer flush
ndspChnWaveBufAdd(channel, &snd->wave_buf); // voltar a tocar

romfsInit() esquecido: nenhum asset carrega

Sintoma: todos os fopen("romfs:/...") devolvem NULL. As fontes, texturas e sons não carregam.

Causa: o romfsInit() não foi chamado no início do programa. Esta chamada é obrigatória antes de qualquer acesso ao sistema de ficheiros RomFS.

Dimensões erradas nos assets do banner do CIA

As dimensões são rígidas no formato CIA:

  • Ícone: PNG de 48×48 píxeis exatos
  • Banner: PNG de 256×128 píxeis exatos
  • Áudio do banner: WAV PCM, de preferência curto (~3 segundos)

Se as dimensões não corresponderem, o bannertool falha em silêncio ou produz um binário corrompido que faz rebentar a instalação.

14. Conclusão: criar o teu próprio jogo homebrew para a Nintendo 3DS

Desenvolver um jogo homebrew para a Nintendo 3DS é um projeto que toca em várias áreas apaixonantes da programação: programação de sistemas embebidos, gráficos 2D com citro2d, áudio de baixo nível com NDSP, gestão de memória específica e cross-compilation ARM. As limitações da plataforma (memória reduzida, duplo ecrã, formatos proprietários) obrigam a escrever código limpo e eficiente — e, sinceramente, deu-me imenso gozo enfrentar estes desafios.

As 6 regras de ouro do desenvolvimento homebrew 3DS

  1. Separa a lógica de jogo da renderização. A lógica portável num ficheiro independente, a renderização 3DS noutro. Foi esta estratégia que permitiu desenvolver o 2048-3DS de forma eficiente.
  2. C2D_TargetClear antes de cada cena. Sempre. Sem exceções. Caso contrário, artefactos na VRAM.
  3. linearAlloc para o áudio, nunca malloc. E nunca te esqueças do DSP_FlushDataCache().
  4. Só WAV PCM em todos os ficheiros áudio. Converte os teus MP3/OGG antes de os integrares.
  5. Testa o .cia além do .3dsx. As permissões são diferentes e um serviço esquecido no RSF provoca um crash.
  6. Declara todos os serviços de sistema no ficheiro RSF, em especial dsp::DSP, csnd:SND, hid:USER e fs:USER.

O projeto 2048 para Nintendo 3DS põe em prática todos os conceitos deste tutorial: renderização em duplo ecrã, animações fluidas, áudio multifaixa, gravação binária, localização em 13 idiomas, sistema de conquistas e empacotamento em .3dsx e .cia. Coloquei-o em open source para que sirva de base aos teus próprios projetos.

Descarregar o 2048 para Nintendo 3DS — disponível gratuitamente em .3dsx e .cia. Dá uma vista de olhos ao código-fonte para ver todos estes conceitos em ação. E se te lançares num projeto homebrew para a 3DS, partilha-o — a comunidade é acolhedora e está sempre pronta a dar uma ajuda.

Para comparar com uma abordagem sem escrever código, o nosso artigo Criar jogo com ChatGPT acompanha o Codex, a IA de programação da OpenAI, enquanto constrói um pequeno RPG retro ao estilo de EarthBound, jogável no navegador.

Recursos para o desenvolvimento homebrew 3DS

  • devkitPro: https://devkitpro.org/, Toolchain oficial para o desenvolvimento homebrew na 3DS
  • Documentação da libctru: https://libctru.devkitpro.org/, Referência completa da API libctru
  • Headers do citro2d: os headers em $DEVKITARM/../libctru/include/citro2d/ são a melhor documentação da biblioteca gráfica
  • Wiki 3dbrew: https://www.3dbrew.org/, Wiki técnico exaustivo da Nintendo 3DS (hardware, formatos, serviços de sistema)
  • Exemplos oficiais: https://github.com/devkitPro/3ds-examples, Exemplos que cobrem gráficos, áudio e rede
  • bannertool: https://github.com/Steveice10/bannertool, Gerador de banners e ícones para o formato CIA
  • makerom: https://github.com/3DSGuy/Project_CTR, Montador de ficheiros CIA/CCI para a Nintendo 3DS

Especificações técnicas da Nintendo 3DS

  • CPU: ARM11 MPCore (ARMv6K) @ 268 MHz, 2 cores (4 na New 3DS)
  • GPU: DMP PICA200 @ 268 MHz
  • RAM: 128 MB (256 MB na New 3DS), com 6 MB de VRAM dedicada
  • Ecrã superior: 400×240 píxeis (800×240 em modo 3D estereoscópico)
  • Ecrã inferior: 320×240 píxeis, tátil resistivo
  • Áudio: 24 canais DSP, saída estéreo
Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.