
Chcesz napisać własną grę homebrew na Nintendo 3DS? Dobra wiadomość: nadal da się to zrobić, a projekt jest naprawdę wciągający. W tym poradniku zbieram wszystko, czego nauczyłem się przy tworzeniu 2048 na Nintendo 3DS, kompletnej gry homebrew z animacjami, dźwiękiem, 13 językami i systemem osiągnięć, od pierwszej linijki kodu po gotowy instalowalny plik .cia.
Zobacz efekt końcowy: 2048 na Nintendo 3DS, do pobrania za darmo w formatach .3dsx i .cia.
Ten przewodnik obejmuje cały pipeline programowania homebrew na 3DS: grafikę 2D w citro2d, dźwięk przez NDSP, ekran dotykowy, system plików RomFS oraz pakowanie do CIA. Cały kod jest w C99. Bez teoretyzowania, tylko działający kod, wzięty wprost z projektu 2048-3DS.
Spis treści
- Czym jest homebrew na 3DS
- Wymagania: instalacja devkitARM i narzędzi do programowania na 3DS
- Architektura projektu homebrew: oddzielenie logiki od renderowania
- Obsługa dwóch ekranów Nintendo 3DS
- Grafika 2D w citro2d
- Obsługa sterowania: D-pad, circle pad i ekran dotykowy
- Dźwięk w homebrew na 3DS: NDSP i format WAV PCM
- System plików: RomFS i karta SD
- Zapis danych na karcie SD
- Makefile 3DS: kompilacja skrośna na ARM z devkitARM
- Generowanie pliku .3dsx dla Homebrew Launchera
- Budowa instalowalnego pliku .cia dla menu HOME
- Pułapki i typowe błędy w programowaniu homebrew na 3DS
- Podsumowanie i materiały na dalszą drogę
1. Czym jest homebrew na Nintendo 3DS?
Homebrew na 3DS to nieoficjalna aplikacja uruchamiana na konsoli Nintendo 3DS z custom firmware (CFW), takim jak Luma3DS. Scena homebrew pozwala swobodnie tworzyć i rozpowszechniać gry, emulatory oraz narzędzia na tę przenośną konsolę.
Nintendo 3DS ma procesor ARM11 (ARMv6K) taktowany zegarem 268 MHz i GPU PICA200, a do tego jedną cechę wyjątkową: dwa ekrany. Górny wyświetla 400×240 pikseli (800×240 w trybie 3D stereoskopowym), a dolny, dotykowy, 320×240 pikseli.
Homebrew na 3DS rozprowadza się w dwóch formatach:
- .3dsx: format uruchamiany przez Homebrew Launcher. Bez instalacji, prosto z karty SD.
- .cia: format instalowalny, który pojawia się w menu HOME konsoli, z ikoną i animowanym banerem. Wymaga custom firmware (CFW).
W 2048-3DS generuję oba formaty, żeby dotrzeć do jak największej liczby graczy. Scena homebrew wciąż ma się dobrze dzięki solidnemu ekosystemowi narzędzi: devkitARM (toolchain), libctru (biblioteka systemowa), citro2d/citro3d (grafika). Jeśli szukasz poligonu do nauki programowania systemów wbudowanych albo tworzenia gier retro, 3DS nadaje się do tego idealnie.
2. Wymagania: instalacja devkitARM i narzędzi do programowania na 3DS
Zanim zaczniesz programować grę na Nintendo 3DS, trzeba zainstalować toolchain do kompilacji skrośnej na ARM oraz biblioteki właściwe dla tej konsoli.
Toolchain devkitARM: kompilator na Nintendo 3DS
devkitARM to oparty na GCC toolchain do kompilacji skrośnej na procesory ARM, dostarczany przez projekt devkitPro. Zawiera kompilator, linker i narzędzia budowania potrzebne, żeby skompilować kod C na 3DS. Instalacja wygląda tak:
# Instalacja na Linuksie/WSL (zalecana do programowania na 3DS)
wget https://apt.devkitpro.org/install-devkitpro-pacman
chmod +x install-devkitpro-pacman
sudo ./install-devkitpro-pacman
# Instalacja pakietów deweloperskich 3DS
sudo dkp-pacman -S 3ds-dev
# Obowiązkowa zmienna środowiskowa
export DEVKITARM=/opt/devkitpro/devkitARMBiblioteki niezbędne w programowaniu homebrew na 3DS
- libctru: niskopoziomowa biblioteka C do usług systemowych 3DS (HID, filesystem, audio, GPU). To fundament każdego programu homebrew na 3DS.
- citro3d: warstwa abstrakcji nad GPU PICA200. Obsługuje render targety, framebuffer i synchronizację grafiki.
- citro2d: nakładka 2D na citro3d. Daje proste prymitywy (prostokąty, koła, elipsy, linie, tekst, sprite’y) w sam raz do gier 2D na 3DS.
Dodatkowe narzędzia do pakowania
- bannertool: generuje pliki banner.bin i icon.bin wymagane przez format CIA
- makerom: składa finalny plik .cia z ELF-a, RSF-a, banera i ikony
- tex3ds: konwertuje obrazy PNG na tekstury .t3x, które citro2d czyta na GPU PICA200
- mkbcfnt: konwertuje fonty TTF do formatu .bcfnt na potrzeby rysowania tekstu na 3DS (obsługuje Unicode, CJK, cyrylicę)
3. Architektura projektu homebrew na 3DS: oddzielenie logiki od renderowania
Klasyczna pułapka na starcie z programowaniem homebrew to pisanie całego kodu bezpośrednio na API konsoli. Efekt: nie da się tego sensownie testować ani debugować. Sam przez to przeszedłem i każdemu radzę to samo, od pierwszego dnia oddziel logikę gry od renderowania.
Dokładnie tak zbudowany jest 2048-3DS: logika gry (plansza, ruchy, scalanie, punkty) jest w 100% przenośna, a renderowanie zostaje specyficzne dla każdej platformy.
Struktura plików projektu 2048-3DS
2048-3ds/
├── source/
│ ├── logic.h # Interface du jeu 2048 (portable, sans dépendance)
│ ├── logic.c # Logique du jeu (grille 4x4, mouvements, score)
│ ├── achievements.h # Système de succès (8 paliers de score)
│ ├── achievements.c # Sauvegarde/chargement binaire des succès
│ ├── lang.h # Localisation (13 langues, compile-time)
│ ├── main_sdl.c # Rendu PC avec SDL2 (simulation double écran)
│ └── main_3ds.c # Rendu Nintendo 3DS avec citro2d
├── romfs/ # Assets embarqués dans le binaire .3dsx/.cia
│ ├── font.bcfnt # Police custom avec glyphes CJK/cyrillique
│ ├── sprites.t3x # Sprite sheet compile (icônes de succès)
│ └── music/ # Musique et effets sonores (WAV PCM obligatoire)
├── assets/ # Ressources PC + assets pour bannière CIA
├── gfx/ # Sources des sprites (PNG + config .t3s)
├── Makefile # Build PC
├── Makefile.3ds # Build 3DS (devkitARM + citro2d)
└── app.rsf # Configuration CIA (permissions, titre, UniqueId)Kod przenośny: logika 2048 bez zależności
Plik logic.c w 2048-3DS nie dołącza żadnego nagłówka specyficznego dla Nintendo 3DS. Korzysta wyłącznie ze standardowych nagłówków C (<stdint.h>, <stdlib.h>, <string.h>, <time.h>). Plik main_3ds.c wywołuje publiczne funkcje logiki:
// logic.h — przenośny interfejs gry 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; // pozycja przed ruchem
int to_r, to_c; // pozycja po ruchu
uint16_t value; // wartość wyświetlana podczas animacji
int merged; // 1 przy scaleniu
} TileAnim;
typedef struct {
uint16_t cells[GRID_SIZE][GRID_SIZE]; // plansza 4x4
uint32_t score;
int won, over;
TileAnim anims[MAX_TILE_ANIMS]; // dane animacji ostatniego ruchu
int anim_count;
int spawn_r, spawn_c; // pozycja nowego kafelka
uint16_t spawn_val; // wartość (2 w 90%, 4 w 10%)
} Game;
void game_init(Game *g); // pusta plansza + 2 kafelki
int game_move(Game *g, Direction dir); // zwraca 1, jeśli plansza się zmieniła
int game_is_over(Game *g); // brak możliwego ruchu
int game_has_won(Game *g); // osiągnięto kafelek >= 2048Makefile 3DS kompiluje logic.c + achievements.c + main_3ds.c, jawnie odfiltrowując main_sdl.c. Logika gry pozostaje ta sama niezależnie od front-endu renderującego.
4. Obsługa dwóch ekranów Nintendo 3DS
Cechą, która odróżnia Nintendo 3DS od każdej innej przenośnej konsoli, jest podwójny ekran. Dobre wykorzystanie obu decyduje o jakości homebrew.
- Ekran górny (top): 400×240 pikseli, bez dotyku. Idealny na główną rozgrywkę. W 2048-3DS to tutaj rysuję planszę 4×4 wraz z animacjami kafelków.
- Ekran dolny (bottom): 320×240 pikseli, dotykowy. Idealny na interfejs, menu i sterowanie. W 2048-3DS wyświetlam na nim wynik, przyciski, ustawienia i osiągnięcia.
Inicjalizacja render targetów w citro2d
// Inicjalizacja systemu graficznego 3DS
gfxInitDefault();
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
C2D_Prepare();
// Render target dla każdego ekranu
C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);Pętla renderowania na dwa ekrany
W każdej klatce trzeba rysować osobno na obu ekranach. Tak wygląda pętla renderowania w 2048-3DS:
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);
// Ekran górny — plansza gry 2048
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_top_screen(&game, anim_phase, progress);
// Ekran dolny — UI (wynik, przyciski, ustawienia)
C2D_TargetClear(bot, col_bot_bg);
C2D_SceneBegin(bot);
render_bot_game(&game, best_score, 0, music_muted);
C3D_FrameEnd(0);Krytyczna pułapka renderowania na 3DS: C2D_TargetClear jest obowiązkowe. Bez
C2D_TargetClear()przed każdą sceną dostaniesz losowe artefakty graficzne, pozostałości VRAM z poprzedniej klatki. VRAM konsoli nie jest czyszczona automatycznie. Ten błąd potrafi mocno zmylić początkujących, bo artefakty zmieniają się w każdej klatce.
Wymiary ekranów: stałe, które musisz mieć
#define TOP_W 400 // szerokość górnego ekranu
#define TOP_H 240 // wysokość górnego ekranu
#define BOT_W 320 // szerokość dolnego ekranu (dotykowy)
#define BOT_H 240 // wysokość dolnego ekranuUwaga: na fizycznej konsoli dolny ekran jest wyśrodkowany w poziomie pod górnym (400 wobec 320 pikseli szerokości). Współrzędne dotyku odpowiadają wprost pikselom dolnego ekranu, z punktem (0,0) w lewym górnym rogu.
5. Grafika 2D w citro2d na Nintendo 3DS
citro2d to referencyjna biblioteka renderowania 2D dla gier homebrew na 3DS. Zbudowana na citro3d i GPU PICA200, sama zajmuje się batchowaniem prymitywów i zarządzaniem teksturami GPU. Poniżej konkretne przykłady użycia, wzięte z 2048-3DS.
Rysowanie prymitywów: prostokąty, koła, elipsy i linie
// Wypełniony prostokąt (kafelki gry 2048)
C2D_DrawRectSolid(x, y, z, largeur, hauteur, couleur);
// Wypełnione koło (zaokrąglone rogi, ikony)
C2D_DrawCircleSolid(centre_x, centre_y, z, rayon, couleur);
// Wypełniona elipsa (ikona muzyki w 2048-3DS)
C2D_DrawEllipseSolid(x, y, z, largeur, hauteur, couleur);
// Linia (kreska wyciszenia dźwięku)
C2D_DrawLine(x1, y1, couleur1, x2, y2, couleur2, epaisseur, z);Parametr z steruje głębią (0.0f dla standardowej płaszczyzny). Kolory na 3DS są w pamięci w formacie ABGR (uwaga: odwrotnie niż klasyczne ARGB). citro2d daje makro C2D_Color32(r, g, b, a), które składa kolor poprawnie:
// Pułapka C99: C2D_Color32() nie jest constexpr
// Makro do globalnych stałych kolorów
#define MAKE_COLOR(r,g,b,a) \
((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))
// Paleta kolorów gry 2048 na 3DS
#define col_grid_bg MAKE_COLOR(0xBB, 0xAD, 0xA0, 0xFF) // tło planszy
#define col_bot_bg MAKE_COLOR(0xFA, 0xF8, 0xEF, 0xFF) // tło dolnego ekranu
#define col_text_dk MAKE_COLOR(0x77, 0x6E, 0x65, 0xFF) // ciemny tekst
#define col_text_lt MAKE_COLOR(0xF9, 0xF6, 0xF2, 0xFF) // jasny tekstZaokrąglone prostokąty: graficzny trik w citro2d
citro2d nie ma prymitywu „zaokrąglony prostokąt”. Na kafelki gry 2048 i przyciski interfejsu wymyśliłem taki sposób: prostokąt centralny, dwa prostokąty boczne i cztery koła w narożnikach:
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;
}
// Środek + boki
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 koła o promieniu r w narożnikach
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);
}Tej funkcji używam w 2048-3DS wszędzie: kafelki, ramki wyniku, przyciski, suwaki głośności, okna potwierdzenia.
Rysowanie tekstu na Nintendo 3DS w formacie .bcfnt
Rysowanie tekstu na 3DS przechodzi przez format .bcfnt (Binary CTR Font). Każdy napis trzeba najpierw „sparsować” do bufora tekstu, a dopiero potem narysować. W 2048-3DS tak wyświetlam wyniki, etykiety przycisków w 13 językach i powiadomienia o osiągnięciach:
// Inicjalizacja buforów tekstu
C2D_TextBuf s_dynamicBuf = C2D_TextBufNew(512);
C2D_Font s_font = C2D_FontLoad("romfs:/font.bcfnt");
// Funkcja pomocnicza: rysowanie wyśrodkowanego tekstu
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);
}
// Ważne: czyść bufor tekstu w każdej klatce
C2D_TextBufClear(s_dynamicBuf);Współczynnik 0.62f jest dobrany tak, żeby tekst był wizualnie poprawnie wyśrodkowany w pionie.
Sprite’y i tekstury GPU w formacie .t3x
Obrazy trzeba przekonwertować do formatu .t3x (tekstura 3DS zoptymalizowana pod GPU PICA200) narzędziem tex3ds. Plik .t3s opisuje sprite sheet. W 2048-3DS osiem ikon osiągnięć (po 32×32 piksele) jest spakowanych w jeden sprite sheet:
# gfx/sprites.t3s — configuration du sprite sheet pour tex3ds
--atlas -f rgba8888 -z auto
sprite_0.png # Debutant (vert)
sprite_1.png # Apprenti (bleu)
sprite_2.png # Competent (violet)
sprite_3.png # Expert (orange)
sprite_4.png # Maitre (rouge)
sprite_5.png # Grand Maitre (rose)
sprite_6.png # Legende (or)
sprite_7.png # Titan (or brillant)// Wczytanie sprite sheeta z RomFS
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");
// Rysowanie ikony osiągnięcia
C2D_Image img = C2D_SpriteSheetGetImage(sheet, index);
C2D_DrawImageAt(img, x, y, 0.0f, NULL, scale_x, scale_y);
// Rysowanie w skali szarości (osiągnięcie zablokowane)
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);Płynne animacje na Nintendo 3DS
Do płynnych animacji w grze homebrew na 3DS użyj osGetTime() (czas w milisekundach) i interpolacji z easingiem. Tak wygląda system animacji w 2048-3DS dla przesuwania i pojawiania się kafelków:
// Czasy animacji w grze 2048
#define ANIM_SLIDE_MS 120 // czas przesunięcia kafelka
#define ANIM_POP_MS 100 // czas "popu" (scalenie/pojawienie się)
typedef enum { ANIM_NONE, ANIM_SLIDING, ANIM_POPPING } AnimPhase;
// Easing kwadratowy: naturalne wyhamowanie
static float ease_out_quad(float t) {
return t * (2.0f - t);
}
// W głównej pętli: interpolacja pozycji
u64 now = osGetTime();
u64 elapsed = now - anim_start;
float progress = (float)elapsed / ANIM_SLIDE_MS;
float t = ease_out_quad(progress);
// Liniowe przesunięcie kafelka
float cur_x = from_px + (to_px - from_px) * t;
float cur_y = from_py + (to_py - from_py) * t;Animacja ma dwie fazy: najpierw przesunięcie (120 ms), potem „pop”, scalone kafelki rosną do 125%, po czym wracają do 100%, a nowy kafelek pojawia się, rosnąc od 0 do 100%. Dzięki temu 2048 na 3DS wygląda równie satysfakcjonująco co oryginał.
6. Obsługa sterowania na Nintendo 3DS: D-pad, circle pad i ekran dotykowy
Nintendo 3DS daje homebrew kilka źródeł wejścia, wszystkie dostępne przez bibliotekę libctru (HID). W 2048-3DS korzystam z trzech: D-pad i circle pad przesuwają kafelki, ekran dotykowy obsługuje przyciski i suwaki.
Odczyt fizycznych przycisków: D-pad oraz A/B/X/Y
hidScanInput(); // Odczyt stanu wejść (raz na klatkę)
u32 kDown = hidKeysDown(); // Przyciski wciśnięte w tej klatce
// D-pad: przesuwanie kafelków w grze 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; }
// Przyciski systemowe
if (kDown & KEY_B) { /* cofnięcie */ }
if (kDown & KEY_START) { break; /* wyjście z aplikacji */ }
if (kDown & KEY_SELECT) { game_init(&game); /* nowa gra */ }Circle Pad: analogowy drążek 3DS i deadzone
circlePosition cpad;
hidCircleRead(&cpad);
// Deadzone 80, żeby uniknąć fałszywych odczytów
// Zakres circle pada: mniej więcej od -155 do +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; }Deadzone przy circle padzie 3DS jest kluczowa. Bez progu drążek non stop rejestruje mikroruchy i wywołuje ruchy, których nikt nie chciał. Wartość 80 (przy zakresie mniej więcej od -155 do +155) sprawdza się w praktyce w grze takiej jak 2048.
Ekran dotykowy: obsługa kliknięć w przyciski interfejsu
Ekran dotykowy 3DS jest rezystancyjny (reaguje na nacisk, nie na pojemność). Współrzędne podawane są wprost w pikselach dolnego ekranu (320×240). W 2048-3DS dotyk obsługuje przyciski „Nowa gra”, „Osiągnięcia”, „Ustawienia”, suwaki głośności i wybór języka:
if (kDown & KEY_TOUCH) {
touchPosition touch;
hidTouchRead(&touch);
int mx = touch.px; // 0..319
int my = touch.py; // 0..239
// Wykrycie kliknięcia przycisku "Nowa gra"
float btn_x = (BOT_W - BTN_W) / 2; // wyśrodkowanie w poziomie
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;
}
}Do pojedynczych naciśnięć (przyciski) używaj hidKeysDown() z KEY_TOUCH, a do interakcji ciągłych (suwaki głośności, przeciąganie), hidKeysHeld().
Główna pętla gry homebrew na 3DS
while (aptMainLoop()) {
u64 now = osGetTime();
audio_music_tick(music_volume, music_muted);
hidScanInput();
u32 kDown = hidKeysDown();
if (kDown & KEY_START) break; // Czyste wyjście
// Obsługa wejść (D-pad, circle pad, dotyk)
// Aktualizacja logiki gry 2048
// Renderowanie obu ekranów
// ...
}aptMainLoop() obsługuje cykl życia aplikacji homebrew: uśpienie, zamknięcie przez system, powrót do menu HOME.
7. Dźwięk w homebrew na 3DS: NDSP i format WAV PCM
Z mojego doświadczenia: dźwięk na Nintendo 3DS to obszar, na którym straciłem najwięcej czasu. Na papierze wygląda prosto, w praktyce pułapki są wszędzie. Backendem audio konsoli jest NDSP (Nintendo DSP): wymaga pliku firmware DSP (dspfirm.bin na karcie SD) i obsługuje miksowanie wielokanałowe z interpolacją liniową.
W 2048-3DS zaimplementowałem muzykę w tle złożoną z 4 utworów oraz efekty dźwiękowe przy ruchu kafelków i odblokowaniu osiągnięć.
Format audio na 3DS: tylko WAV PCM
Największa pułapka audio na 3DS: konsola obsługuje wyłącznie surowy WAV PCM (żadnego MP3, OGG ani WAV-a skompresowanego ADPCM). Pliki muszą być w PCM 8- lub 16-bitowym, mono albo stereo. Każdy inny format zostanie po cichu zignorowany albo wywoła crasha.
Alokacja pamięci na audio 3DS: linearAlloc zamiast malloc
Bufory audio na 3DS trzeba alokować przez linearAlloc(), a nie malloc(). Pamięć liniowa jest bezpośrednio dostępna dla procesora DSP, zwykła sterta nie jest.
// Struktura przechowująca plik audio WAV na 3DS
typedef struct {
u8 *data; // Dane PCM (alokowane przez linearAlloc)
u32 size; // Rozmiar w bajtach
u32 sample_rate; // Częstotliwość próbkowania
u16 channels; // 1 (mono) lub 2 (stereo)
u16 bits_per_sample; // 8 lub 16 bitów
ndspWaveBuf wave_buf; // Bufor NDSP
int loaded; // Flaga udanego wczytania
} WavSound;Wczytywanie plików WAV w homebrew na 3DS
static int wav_load(WavSound *snd, const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) return -1;
// Odczyt i walidacja nagłówka 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;
}
// Przejście po chunkach 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) {
// Wyciągnięcie channels, sample_rate, bits_per_sample
// ...
} else if (memcmp(chunk_id, "data", 4) == 0) {
// WAŻNE: linearAlloc, nie malloc!
snd->data = (u8 *)linearAlloc(chunk_size);
fread(snd->data, 1, chunk_size, f);
} else {
fseek(f, chunk_size, SEEK_CUR);
}
}
// Przygotowanie bufora 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;
// OBOWIĄZKOWE: zrzucenie cache'u CPU do DSP
DSP_FlushDataCache(snd->data, snd->size);
return 0;
}DSP_FlushDataCache jest obowiązkowe po każdym zapisie do bufora audio na 3DS. Bez tego wywołania DSP czyta uszkodzone dane, bo cache CPU nie jest spójny z pamięcią DSP. To przyczyna numer jeden cichych błędów audio na Nintendo 3DS.
Inicjalizacja audio NDSP
Tak wygląda inicjalizacja dźwięku w 2048-3DS:
static void audio_init(void)
{
if (ndspInit() != 0) return;
ndspSetOutputMode(NDSP_OUTPUT_STEREO);
// Kanał 0: muzyka w tle
// Kanał 1: SFX (ruch kafelka)
// Kanał 2: SFX (odblokowane osiągnięcie)
for (int ch = 0; ch < 3; ch++) {
ndspChnReset(ch);
ndspChnSetInterp(ch, NDSP_INTERP_LINEAR);
ndspChnSetFormat(ch, NDSP_FORMAT_STEREO_PCM16);
}
}Odtwarzanie dźwięku i sterowanie głośnością na 3DS
// NDSP: dodanie bufora audio do kanału
snd->wave_buf.status = NDSP_WBUF_FREE; // reset statusu
DSP_FlushDataCache(snd->data, snd->size);
ndspChnWaveBufAdd(channel, &snd->wave_buf);
// Głośność NDSP: tablica 12 floatów (miks stereo na kanał)
float vol = (float)music_volume / 128.0f; // 0..128 -> 0.0..1.0
float mix[12] = {0};
mix[0] = vol; // kanał lewy
mix[1] = vol; // kanał prawy
ndspChnSetMix(0, mix);Automatyczne przechodzenie między utworami
W 2048-3DS cztery utwory lecą jeden po drugim automatycznie (intro, music1, music2, music3). W każdej klatce sprawdzam, czy bieżący utwór się skończył:
static void audio_music_tick(int music_volume, int music_muted)
{
if (!s_audio_init || music_muted) return;
if (!ndspChnIsPlaying(0)) {
// Przejście do następnego utworu (pętla cykliczna)
s_music_current = (s_music_current + 1) % MUSIC_TRACK_COUNT;
audio_play_current_track(music_volume, music_muted);
}
}Sprzątanie po audio na koniec programu
// Czyste zwolnienie zasobów audio
ndspChnReset(0);
ndspChnReset(1);
ndspChnReset(2);
ndspExit();
// Zwolnienie pamięci liniowej (nie free, tylko 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. System plików 3DS: RomFS i karta SD
Nintendo 3DS udostępnia aplikacjom homebrew dwa systemy plików: RomFS na zasoby tylko do odczytu i SDMC do odczytu i zapisu na karcie SD.
RomFS: zasoby wbudowane w binarkę homebrew
RomFS (Read-Only Memory FileSystem) pozwala wbudować pliki wprost w binarkę .3dsx lub .cia. Idealne dla zasobów, które się nie zmieniają: fontów, tekstur, muzyki, sprite’ów. W 2048-3DS katalog romfs/ zawiera font .bcfnt z obsługą CJK, sprite sheet osiągnięć i 6 plików WAV:
// Obowiązkowa inicjalizacja przed każdym dostępem do RomFS
romfsInit();
// Dostęp do zasobów przez prefiks 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");Zawartość katalogu romfs/ Makefile 3DS wbudowuje automatycznie:
# W Makefile.3ds
ROMFS := romfsFormaty zasobów właściwe dla Nintendo 3DS
- .bcfnt: font bitmapowy skompilowany przez
mkbcfnt. Obsługuje pełny Unicode (CJK, cyrylica, znaki diakrytyczne). W 2048-3DS jeden font pokrywa wszystkie 13 języków. - .t3x: tekstura GPU skompilowana przez
tex3ds. Format zoptymalizowany pod PICA200. Ładuje się prosto do VRAM, bez konwersji. - .wav: dźwięk w surowym PCM. Żadnej konwersji w czasie działania, ale musi być nieskompresowany (patrz sekcja o audio).
SDMC: zapis danych na karcie SD konsoli
Do zapisu danych (wyniki, ustawienia, postęp) homebrew na 3DS pisze na kartę SD z prefiksem sdmc:/:
// Ścieżki zapisu dla 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"
// Utworzenie katalogu zapisu przy starcie
#include <sys/stat.h>
mkdir("sdmc:/3ds", 0777);
mkdir(SAVE_DIR, 0777);Do zapisu służą standardowe funkcje C (fopen, fwrite, fclose). Operacje na plikach w SDMC nie wymagają żadnego API specyficznego dla 3DS.
9. Zapis danych na karcie SD w homebrew na 3DS
W grze homebrew na 3DS zapis binarny jest najprostszy i najtańszy. Tak 2048-3DS utrwala osiągnięcia i ustawienia.
Binarny zapis systemu osiągnięć
Gra 2048-3DS ma 8 osiągnięć (progi punktowe: 500, 1000, 2500, 5000, 10000, 20000, 50000, 100000 punktów). Zapis zajmuje 8 bajtów, jeden bajt na osiągnięcie:
// Zapis: 8 bajtów (0x00 = zablokowane, 0x01 = odblokowane)
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;
}
// Wczytanie z wartościami domyślnymi, jeśli pliku nie ma
int achievements_load(Achievements *a, const char *path)
{
achievements_init(a); // domyślnie wszystkie zablokowane
FILE *f = fopen(path, "rb");
if (!f) return -1; // pierwsze uruchomienie: brak pliku
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;
}Zapis ustawień użytkownika
Ustawienia 2048-3DS (język, głośność muzyki, głośność efektów, stan wyciszenia) mieszczą się w 4 bajtach:
// Format: [0] = język, [1] = głośność muzyki, [2] = głośność SFX, [3] = wyciszenie
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);
}Zalety tego podejścia w grze homebrew: stały rozmiar, zero parsowania, brak zależności od biblioteki JSON/XML, natychmiastowy odczyt. Ustawienia zapisują się automatycznie, gdy gracz wychodzi z menu ustawień albo z aplikacji.
10. Makefile 3DS: kompilacja skrośna na ARM z devkitARM
System budowania homebrew na 3DS opiera się na regułach dostarczanych przez devkitARM. Zrozumienie Makefile’a 3DS jest kluczowe przy debugowaniu problemów z kompilacją.
Struktura Makefile’a w projekcie homebrew na 3DS
# Sprawdzenie, czy devkitARM jest skonfigurowany
ifeq ($(strip $(DEVKITARM)),)
$(error "Please set DEVKITARM in your environment")
endif
include $(DEVKITARM)/3ds_rules
# Konfiguracja projektu homebrew
TARGET := 2048
BUILD := build_3ds
SOURCES := source
ROMFS := romfs
# Metadane widoczne w Homebrew Launcherze i menu HOME
APP_TITLE := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR := GekkodeFlagi kompilacji ARM dla Nintendo 3DS
ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft
CFLAGS := -g -Wall -Wextra -O2 -mword-relocations \
-ffunction-sections $(ARCH) -std=c99
CFLAGS += $(INCLUDE) -D__3DS__Co robi każda flaga kompilacji skrośnej ARM na 3DS:
-march=armv6k: architektura procesora ARM11 w Nintendo 3DS-mtune=mpcore: optymalizuje kod pod rdzeń MPCore-mfloat-abi=hard: używa VFP (sprzętowej jednostki zmiennoprzecinkowej), kluczowe dla wydajności grafiki-mword-relocations: generuje relokacje wymagane przez format 3DSX-ffunction-sections: pozwala linkerowi usunąć martwy kod i zmniejszyć rozmiar binarki-D__3DS__: definiuje makro do warunkowych#ifdefmiędzy PC a 3DS
Linkowanie bibliotek citro2d i libctru
LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS := -lcitro2d -lcitro3d -lctru -lmKolejność bibliotek ma znaczenie dla linkera GNU: -lcitro2d zależy od -lcitro3d, a ten od -lctru. Zawsze idź od najwyższej abstrakcji do najniższej.
Filtrowanie źródeł: wykluczenie renderera PC z builda 3DS
# Wykluczenie main_sdl.c z builda 3DS (kompilujemy tylko main_3ds.c)
CFILES := $(filter-out main_sdl.c, \
$(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))))Dzięki temu w buildzie 3DS kompiluje się wyłącznie main_3ds.c.
Polecenia budowania
# Kompilacja homebrew do .3dsx
make -f Makefile.3ds
# Kompilacja i wygenerowanie instalowalnego .cia
make -f Makefile.3ds cia
# Pełne czyszczenie
make -f Makefile.3ds clean11. Generowanie pliku .3dsx dla Homebrew Launchera
Format .3dsx to standardowy format homebrew na Nintendo 3DS, uruchamiany przez Homebrew Launcher. Najprostszy w dystrybucji: jeden plik do skopiowania na kartę SD.
Makefile 3DS generuje automatycznie dwa pliki:
2048.3dsx: plik wykonywalny homebrew z wbudowanym RomFS2048.smdh: metadane SMDH (tytuł, autor, opis, ikona)
SMDH: metadane twojego homebrew na 3DS
Plik SMDH (Simple Metadata Header) zawiera informacje pokazywane w Homebrew Launcherze:
# Zdefiniowane w Makefile.3ds
APP_TITLE := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR := GekkodeIkona to PNG 48×48 pikseli, wykrywany automatycznie, jeśli w katalogu głównym projektu istnieje icon.png albo 2048.png.
Wbudowanie RomFS w plik .3dsx
RomFS ląduje bezpośrednio w pliku .3dsx. Użytkownik końcowy ma do czynienia tylko z jednym plikiem:
_3DSXFLAGS += --romfs=$(CURDIR)/romfs12. Budowa instalowalnego pliku .cia dla menu HOME konsoli
Format .cia (CTR Importable Archive) to instalowalny format Nintendo 3DS. Po instalacji przez FBI albo inny menedżer tytułów gra pojawia się w menu HOME konsoli z własną ikoną, animowanym banerem i dźwiękiem powitalnym.
W tym formacie rozprowadzam 2048 na Nintendo 3DS w wersji .cia.
Uwaga: to zdecydowanie najbardziej podchwytliwy etap programowania homebrew na 3DS. Spędziłem masę czasu, próbując zrozumieć, dlaczego plik .cia potrafi działać bez zarzutu w emulatorze Citra i wywalać się natychmiast na prawdziwej 3DS. Przyczyną prawie zawsze jest źle skonfigurowany plik RSF. To, co niżej, jest wynikiem wielu godzin testów na realnym sprzęcie, opisuję każdy parametr, żebyś nie musiał się z tym męczyć.
Zasoby wymagane do zbudowania .cia
- Ikona: PNG dokładnie 48×48 pikseli (pokazywana w menu HOME)
- Obraz banera: PNG dokładnie 256×128 pikseli (pokazywany u góry ekranu po zaznaczeniu gry)
- Dźwięk banera: WAV PCM 16-bit, 44100 Hz, stereo, około 3 sekund (odtwarzany po zaznaczeniu gry w menu HOME). Format jest sztywny, do konwersji użyj
ffmpeg:
# Konwersja dowolnego audio do WAV zgodnego z bannertool
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wavEtapy budowy pliku CIA na 3DS
Budowa .cia składa się z 4 etapów. Zalecane podejście to szablon RSF ze zmiennymi $(VARIABLE), podstawianymi przez flagi -DVARIABLE="valeur" narzędzia makerom. Ta metoda oddziela konfigurację (w Makefile) od szablonu uprawnień (w RSF), przez co build jest czystszy i nadaje się do wielokrotnego użytku:
# 1. Zbudowanie binarki ELF
make -f Makefile.3ds
# 2. Wygenerowanie banera (obraz + dźwięk startowy)
bannertool makebanner \
-i assets/banner.png \
-a assets/music/banner.wav \
-o build_3ds/banner.bnr
# 3. Wygenerowanie ikony SMDH do menu HOME
bannertool makesmdh \
-s "2048" \
-l "2048 - puzzle game for 3DS" \
-p "Gekkode" \
-i icon.png \
-o build_3ds/icon.icn
# 4. Złożenie finalnego .cia przez 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"Każda flaga -DXXX="valeur" zastępuje odpowiadającą jej zmienną $(XXX) w pliku RSF. Dzięki temu mechanizmowi masz jeden szablon RSF do wszystkich projektów, z projektu na projekt zmieniają się tylko flagi -D.
Ważne flagi makerom:
-target t: cel „test” (dla homebrew niepodpisanego przez Nintendo)-exefslogo: dołącza logo do ExeFS (potrzebne do ekranu powitalnego przy starcie)-major / -minor / -micro: wersja tytułu (widoczna w ustawieniach systemowych)
Plik RSF: konfiguracja uprawnień twojego homebrew w CIA
Plik RSF (ROM Specification File) to najbardziej krytyczny element builda CIA. Definiuje metadane, uprawnienia systemowe, dozwolone usługi i dostępne wywołania systemowe. Niekompletny RSF = natychmiastowy crash na prawdziwym sprzęcie, nawet jeśli w Citrze wszystko chodzi.
Poniżej pełny szablon RSF użyty w 2048-3DS, dopracowany po wielu testach na realnym sprzęcie. Każdą sekcję omawiam dalej:
BasicInfo: tożsamość tytułu
BasicInfo:
Title : $(APP_TITLE)
ProductCode : $(APP_PRODUCT_CODE)
Logo : NintendoTitle: nazwa widoczna w ustawieniach systemowych. Korzysta ze zmiennej$(APP_TITLE), podstawianej przez-DAPP_TITLE="2048".ProductCode: identyfikator w formacieCTR-H-XXXX.Hoznacza homebrew. Wybierz unikalny kod (np.CTR-H-2048).Logo: animowany splash screen pokazywany przy starcie.Nintendo: oficjalne animowane logo 3DS (zalecane, to zachowanie standardowe)Homebrew: logo społeczności homebrewLicensed/Distributed: warianty logo NintendoNone: lepiej unikać, na niektórych wersjach firmware’u potrafi wywołać crash
RomFs: zasoby wbudowane
RomFs:
RootPath: $(APP_ROMFS)Ścieżka do katalogu romfs/ z wbudowanymi zasobami (sprite’y, fonty, audio). Przekazywana przez -DAPP_ROMFS="romfs". Jeśli twoja aplikacja nie ma RomFS, tę sekcję można pominąć.
TitleInfo: unikalna identyfikacja
TitleInfo:
Category : $(APP_CATEGORY)
UniqueId : $(APP_UNIQUE_ID)Category: dla gry lub narzędzia homebrew zawszeApplication.UniqueId: unikalny identyfikator szesnastkowy z zakresu od0xF0000do0xFFFFF(zakres zarezerwowany dla homebrew). Każdy homebrew zainstalowany na konsoli musi mieć inny UniqueId, żeby nie było konfliktów. Dla 2048 używam0xF2048.
Option: opcje pakowania
Option:
UseOnSD : $(APP_USE_ON_SD)
FreeProductCode : true
MediaFootPadding : false
EnableCrypt : $(APP_ENCRYPTED)
EnableCompress : trueUseOnSD:true(obowiązkowe dla homebrew instalowanego na karcie SD).FreeProductCode:true, żeby makerom przyjął dowolny ProductCode (bez sprawdzania formatu Nintendo).EnableCrypt:false(homebrew nie ma kluczy szyfrujących Nintendo).EnableCompress:true, żeby skompresować sekcję .code w ExeFS (zmniejsza rozmiar .cia).
AccessControlInfo: sekcja krytyczna
To tutaj rozstrzyga się 90% crashy CIA. Ta sekcja określa wszystko, co twoja aplikacja może robić na konsoli. Wystarczy, że zabraknie jednego wywołania systemowego albo jednej usługi, a CIA wywali się od razu na prawdziwym sprzęcie, Citra takie ograniczenia po prostu ignoruje.
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 : trueParametry do dostosowania pod własny projekt:
MemoryType:Applicationdla zwykłej gry lub narzędzia.Systemdla modułu systemowego.SystemMode: ilość przydzielonego RAM-u. Standardem jest64MB.96MBwybieraj tylko dla aplikacji wyłącznie na New 3DS, którym brakuje pamięci.SystemModeExt:Legacydla zgodności z Old 3DS i New 3DS.CpuSpeed:268MHzwłącza boost taktowania na New 3DS (na Old 3DS spada z powrotem do 268 MHz, dzieje się to automatycznie).EnableL2Cache: domyślniefalse. Ustawtruedla aplikacji mocno obciążających CPU na New 3DS (na Old 3DS może powodować niestabilność).
Dalej idą cztery podsekcje uprawnień. Moja rada: wpisz pełny zestaw. Zwykły homebrew nie używa wszystkich tych SVC i usług, ale zadeklarowanie ich na zapas nie kosztuje nic ani w wydajności, ani w bezpieczeństwie na konsoli z CFW, za to pominięcie jednej pozycji kończy się natychmiastowym crashem:
FileSystemAccess: dostęp do systemu plików
FileSystemAccess:
- CategorySystemApplication
- CategoryHardwareCheck
- CategoryFileSystemTool
- Debug
- TwlCardBackup
- TwlNandData
- Boss
- DirectSdmc
- Core
- CtrNandRo
- CtrNandRw
- CtrNandRoWrite
- CategorySystemSettings
- CardBoard
- ExportImportIvs
- DirectSdmcWrite
- SwitchCleanup
- SaveDataMove
- Shop
- Shell
- CategoryHomeMenu
- SeedDBDla zwykłego homebrew najważniejsze są: DirectSdmc + DirectSdmcWrite (odczyt i zapis SD) oraz Core. Bezpieczniej jednak wpisać cały zestaw.
IoAccessControl: niskopoziomowy dostęp I/O
IoAccessControl:
- FsMountNand
- FsMountNandRoWrite
- FsMountTwln
- FsMountWnand
- FsMountCardSpi
- UseSdif3
- CreateSeed
- UseCardSpiSystemCallAccess: wywołania systemowe ARM11 (SVC)
Ta lista określa, których wywołań systemowych (supervisor calls) może użyć twoja aplikacja. To najbardziej krytyczna sekcja: jeśli brakuje SVC, który wywoła libctru, konsola wywala się natychmiast. Wpisz wszystkie SVC od 1 do 125:
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 : 125Ważne: numery nie są ciągłe, są w nich luki (63-70, 89-95, 110-111, 116). To normalne: te SVC są zarezerwowane albo niezaimplementowane w jądrze 3DS.
ServiceAccessControl: usługi systemowe
Usługi to wysokopoziomowe API konsoli. Każdy moduł libctru korzysta z jednej lub kilku usług. Jeśli usługi nie ma na tej liście, wywołanie srvGetServiceHandle() zawiedzie, a inicjalizacja odpowiedniej biblioteki się wywali:
ServiceAccessControl:
- APT:U # Cykl życia aplikacji (aptMainLoop)
- ac:u # Konfiguracja sieci
- am:net # Application Manager (instalacja tytułów)
- boss:U # SpotPass
- cam:u # Kamera
- cecd:u # StreetPass
- cfg:nor # Konfiguracja NOR
- cfg:u # Konfiguracja systemu (język, region)
- csnd:SND # Audio CSND
- dsp::DSP # Audio NDSP (główny backend)
- frd:u # Lista znajomych
- fs:USER # System plików (SDMC, RomFS)
- gsp::Gpu # GPU PICA200 (grafika)
- gsp::Lcd # Sterowanie ekranem LCD
- hid:USER # Wejście (przyciski, pad, dotyk)
- http:C # Klient HTTP
- ir:rst # Podczerwień (C-stick New 3DS)
- ir:u # Podczerwień ogólna
- ir:USER # Podczerwień użytkownika
- mic:u # Mikrofon
- mcu::HWC # Mikrokontroler sprzętowy
- ndm:u # Network Daemon Manager
- news:s # Powiadomienia
- nwm::EXT # Rozszerzony Network Manager
- nwm::UDS # Network Manager UDS (local wireless)
- ptm:sysm # Power/Timer Manager systemowy
- ptm:u # Power/Timer Manager użytkownika
- pxi:dev # Dostęp do urządzeń PXI
- soc:U # Gniazda (sieć TCP/UDP)
- ssl:C # SSL/TLS
- y2r:u # Konwersja YUV na RGBW zwykłej grze homebrew niezbędne usługi to: APT:U, fs:USER, gsp::Gpu, hid:USER. Dorzuć dsp::DSP, jeśli używasz dźwięku, i cfg:u, jeśli czytasz konfigurację systemu. Tak jak przy SVC, bezpieczniej jest wpisać cały zestaw, zadeklarowanie nieużywanej usługi nic nie kosztuje.
SystemControlInfo: stos, zapis i zależności
SystemControlInfo:
SaveDataSize: 0KB
RemasterVersion: $(APP_VERSION_MAJOR)
StackSize: 0x40000
Dependency:
ac: 0x0004013000002402
am: 0x0004013000001502
boss: 0x0004013000003402
camera: 0x0004013000001602
cecd: 0x0004013000002602
cfg: 0x0004013000001702
codec: 0x0004013000001802
csnd: 0x0004013000002702
dlp: 0x0004013000002802
dsp: 0x0004013000001a02
friends: 0x0004013000003202
gpio: 0x0004013000001b02
gsp: 0x0004013000001c02
hid: 0x0004013000001d02
http: 0x0004013000002902
i2c: 0x0004013000001e02
ir: 0x0004013000003302
mcu: 0x0004013000001f02
mic: 0x0004013000002002
ndm: 0x0004013000002b02
news: 0x0004013000003502
nim: 0x0004013000002c02
nwm: 0x0004013000002d02
pdn: 0x0004013000002102
ps: 0x0004013000003102
ptm: 0x0004013000002202
ro: 0x0004013000003702
socket: 0x0004013000002e02
spi: 0x0004013000002302
ssl: 0x0004013000002f02SaveDataSize:0KB, jeśli zapisujesz bezpośrednio na kartę SD (tak robi większość homebrew). Podaj rozmiar, jeśli korzystasz z systemowego API zapisu.StackSize:0x40000(256 KB) to solidna wartość domyślna. Zwiększ ją, jeśli twoja aplikacja mocno korzysta z rekurencji albo alokuje duże tablice na stosie.Dependency: title ID modułów systemowych, od których zależy twoja aplikacja. Uwaga: title ID NIE mogą mieć sufiksuL. Wpisanie0x0004013000002402Lzamiast0x0004013000002402potrafi wywołać ciche błędy. Przepisz powyższą listę bez zmian.
IORegisterMapping i MemoryMapping
IORegisterMapping:
- 1ff00000-1ff7ffff
MemoryMapping:
- 1f000000-1f5fffff:rTe sekcje określają dostępne zakresy pamięci i rejestrów I/O. Przepisz powyższe wartości bez zmian, pokrywają potrzeby wszystkich typowych homebrew.
Dostosowanie RSF do własnego projektu
Powyższy szablon RSF jest uniwersalny i wielokrotnego użytku. Żeby użyć go w nowym projekcie, wystarczy zmienić flagi -D w poleceniu makerom:
Flaga -D | Wartość w 2048 | Co zmienić |
|---|---|---|
APP_TITLE | 2048 | Nazwa twojej aplikacji |
APP_PRODUCT_CODE | CTR-H-2048 | unikalny CTR-H-XXXX |
APP_UNIQUE_ID | 0xF2048 | unikalny hex w zakresie 0xF0000-0xFFFFF |
APP_ROMFS | romfs | Ścieżka twojego katalogu romfs |
APP_SYSTEM_MODE | 64MB | 96MB, jeśli wyłącznie New 3DS |
APP_VERSION_MAJOR | 1 | Numer wersji głównej |
Pozostałe wartości (APP_ENCRYPTED=false, APP_CATEGORY=Application, APP_USE_ON_SD=true itd.) są takie same dla każdego homebrew.
Automatyzacja builda CIA w Makefile
# Zmienne na górze 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
# Cel CIA — jedno "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)"Rozszerzenia .bnr i .icn: pliki generowane przez
bannertoolmają rozszerzenia.bnr(banner) i.icn(icon). Część poradników używa.bin, ale poprawne rozszerzenia to.bnri.icn, tak robią referencyjne buildtoole.
13. Pułapki i typowe błędy w programowaniu homebrew na 3DS
W każdą z tych ścian wyrżnąłem przy 2048-3DS i bardzo bym chciał mieć tę listę pod ręką od pierwszego dnia. Oto najbardziej frustrujące błędy i sposoby, żeby ich uniknąć, oszczędzą ci sporo godzin debugowania przy pierwszej własnej grze homebrew na Nintendo 3DS.
Artefakty VRAM: duch na ekranie 3DS
Objaw: na ekranie pojawiają się losowe kolorowe piksele, inne w każdej klatce.
Przyczyna: brak C2D_TargetClear() przed C2D_SceneBegin(). VRAM konsoli nie jest czyszczona automatycznie, zostają w niej resztki poprzedniej klatki albo przypadkowe dane.
// ŹLE — artefakty VRAM gwarantowane
C2D_SceneBegin(top);
render_game();
// POPRAWNIE — zawsze czyść przed rysowaniem
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_game();linearAlloc kontra malloc: pułapka pamięci audio na 3DS
Objaw: cisza albo zamiast dźwięku same trzaski.
Przyczyna: bufory audio na 3DS trzeba alokować przez linearAlloc(). Pamięć zwrócona przez malloc() jest niedostępna dla procesora DSP. Zwalniać też trzeba przez linearFree(), a nie free().
// ŹLE — DSP nie może odczytać tej pamięci
u8 *audio_buf = malloc(size);
// POPRAWNIE — pamięć liniowa dostępna dla DSP
u8 *audio_buf = linearAlloc(size);
// ... użycie ...
linearFree(audio_buf);Zapomniane DSP_FlushDataCache: zepsuty dźwięk na 3DS
Objaw: dźwięk jest zniekształcony, rozjechany albo zupełnie nie ten.
Przyczyna: cache CPU i pamięć DSP w 3DS nie są spójne. Po każdym zapisie do bufora audio trzeba jawnie zrzucić cache:
fread(snd->data, 1, chunk_size, f);
DSP_FlushDataCache(snd->data, snd->size); // OBOWIĄZKOWE!Zły format audio: kompletna cisza
Objaw: brak dźwięku albo crash przy wczytywaniu.
Przyczyna: plik WAV jest skompresowany (ADPCM, zapakowany MP3 itd.) zamiast być w surowym PCM. 3DS obsługuje wyłącznie nieskompresowany PCM. Sprawdź i przekonwertuj:
# Sprawdzenie kodeka pliku WAV
ffprobe music.wav 2>&1 | grep "Audio:"
# Powinno pokazać: pcm_s16le (16 bitów) lub pcm_u8 (8 bitów)
# Konwersja MP3 do WAV PCM zgodnego z 3DS
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 22050 music.wavC2D_Color32 nie jest constexpr w C99
Objaw: błąd kompilacji „initializer element is not constant” przy citro2d w C99.
Przyczyna: C2D_Color32() to funkcja inline w C, a nie wyrażenie stałe. Nie da się nią zainicjalizować zmiennych globalnych w C99. Rozwiązanie:
// ŹLE w C99
static u32 my_color = C2D_Color32(0xFF, 0x00, 0x00, 0xFF);
// POPRAWNIE — makro MAKE_COLOR zamiast 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);.3dsx działa, a .cia się wywala: uprawnienia w RSF
Objaw: homebrew chodzi bez zarzutu jako .3dsx w Homebrew Launcherze, ale jako .cia wywala się natychmiast (często z komunikatem „ErrDisp: An error has occurred” albo „SD Card was removed”). W Citrze .cia działa.
Przyczyna: format .3dsx dziedziczy szerokie uprawnienia Homebrew Launchera. Format .cia ma własne uprawnienia, zdefiniowane w pliku RSF. Citra ignoruje ograniczenia uprawnień i dlatego .cia działa w emulacji, ale nie na sprzęcie. Trzy główne przyczyny:
- Niekompletny SystemCallAccess: jeśli SVC używany przez libctru nie jest zadeklarowany, jądro ARM11 odrzuca wywołanie i zabija proces. Wpisz wszystkie SVC od 1 do 125.
- Brak wpisu w ServiceAccessControl: jeśli usługa (np.
dsp::DSP,gsp::Gpu) nie jest zadeklarowana,srvGetServiceHandle()zawodzi i inicjalizacja odpowiedniej biblioteki się wywala. Wpisz wszystkie usługi wymienione w sekcji 12. - Logo: None w RSF: na niektórych firmware’ach brak logo w ExeFS powoduje crash przy starcie. Używaj
Logo: Nintendorazem z flagą-exefslogo.
Rozwiązanie: weź pełny szablon RSF z sekcji 12, z kompletnym zestawem SVC, usług i uprawnień. To najpewniejszy sposób na .cia, które zadziała na prawdziwym sprzęcie.
Niezresetowany ndspWaveBuf: dźwięk leci tylko raz
Objaw: efekt dźwiękowy odtwarza się poprawnie za pierwszym razem, a potem już nigdy.
Przyczyna: po odtworzeniu status struktury ndspWaveBuf zostaje na „done”. Przed ponownym odtworzeniem trzeba zresetować status i jeszcze raz zrzucić cache:
snd->wave_buf.status = NDSP_WBUF_FREE; // reset
DSP_FlushDataCache(snd->data, snd->size); // ponowny flush cache'u
ndspChnWaveBufAdd(channel, &snd->wave_buf); // ponowne odtworzenieZapomniane romfsInit(): żaden zasób się nie ładuje
Objaw: wszystkie fopen("romfs:/...") zwracają NULL. Fonty, tekstury i dźwięki się nie ładują.
Przyczyna: na starcie programu nie wywołano romfsInit(). To wywołanie jest obowiązkowe przed jakimkolwiek dostępem do systemu plików RomFS.
Złe wymiary zasobów banera CIA
W formacie CIA wymiary są sztywne:
- Ikona: PNG dokładnie 48×48 pikseli
- Baner: PNG dokładnie 256×128 pikseli
- Dźwięk banera: WAV PCM, najlepiej krótki (~3 sekundy)
Jeśli wymiary się nie zgadzają, bannertool po cichu zawiedzie albo wyprodukuje uszkodzoną binarkę, która wywali instalację.
14. Podsumowanie: własna gra homebrew na Nintendo 3DS
Tworzenie gry homebrew na Nintendo 3DS to projekt, który dotyka wielu ciekawych obszarów programowania: programowania systemów wbudowanych, grafiki 2D w citro2d, niskopoziomowego audio przez NDSP, specyficznego zarządzania pamięcią i kompilacji skrośnej na ARM. Ograniczenia platformy (mało pamięci, dwa ekrany, zamknięte formaty) wymuszają czysty i wydajny kod, a szczerze mówiąc, zmierzenie się z tymi wyzwaniami sprawiło mi ogromną frajdę.
6 złotych zasad programowania homebrew na 3DS
- Oddziel logikę gry od renderowania. Przenośna logika w osobnym pliku, renderowanie na 3DS w drugim. To dzięki tej strategii 2048-3DS powstał sprawnie.
- C2D_TargetClear przed każdą sceną. Zawsze. Bez wyjątków. Inaczej artefakty VRAM.
- linearAlloc do audio, nigdy malloc. I nigdy nie zapominaj o
DSP_FlushDataCache(). - Tylko WAV PCM we wszystkich plikach dźwiękowych. Przekonwertuj MP3/OGG, zanim je wrzucisz.
- Testuj .cia, nie tylko .3dsx. Uprawnienia są inne, a jedna zapomniana usługa w RSF kończy się crashem.
- Zadeklaruj wszystkie usługi systemowe w pliku RSF, w szczególności
dsp::DSP,csnd:SND,hid:USERifs:USER.
Projekt 2048 na Nintendo 3DS stosuje w praktyce każdy koncept z tego poradnika: renderowanie na dwóch ekranach, płynne animacje, wielościeżkowe audio, zapis binarny, lokalizację na 13 języków, system osiągnięć oraz pakowanie do .3dsx i .cia. Wypuściłem go jako open source, żeby posłużył za bazę pod twoje własne projekty.
Pobierz 2048 na Nintendo 3DS, za darmo, w .3dsx i .cia. Zajrzyj do kodu źródłowego, żeby zobaczyć wszystkie te koncepty w akcji. A jeśli sam zabierzesz się za projekt homebrew na 3DS, śmiało się nim pochwal, społeczność jest życzliwa i zawsze gotowa pomóc.
Dla porównania z podejściem bez pisania kodu: w artykule Stworzyć grę z ChatGPT Codex, AI do programowania od OpenAI, buduje małe retro RPG w duchu EarthBound, w które zagrasz w przeglądarce.
Materiały do programowania homebrew na 3DS
- devkitPro:
https://devkitpro.org/, oficjalny toolchain do programowania homebrew na 3DS - Dokumentacja libctru:
https://libctru.devkitpro.org/, pełna referencja API libctru - Nagłówki citro2d: pliki nagłówkowe w
$DEVKITARM/../libctru/include/citro2d/to najlepsza dokumentacja tej biblioteki graficznej - 3dbrew Wiki:
https://www.3dbrew.org/, wyczerpujące wiki techniczne Nintendo 3DS (hardware, formaty, usługi systemowe) - Oficjalne przykłady:
https://github.com/devkitPro/3ds-examples, przykłady obejmujące grafikę, dźwięk i sieć - bannertool:
https://github.com/Steveice10/bannertool, generator banerów i ikon dla formatu CIA - makerom:
https://github.com/3DSGuy/Project_CTR, narzędzie składające pliki CIA/CCI dla Nintendo 3DS
Specyfikacja techniczna Nintendo 3DS
- CPU: ARM11 MPCore (ARMv6K) @ 268 MHz, 2 rdzenie (4 w New 3DS)
- GPU: DMP PICA200 @ 268 MHz
- RAM: 128 MB (256 MB w New 3DS), w tym 6 MB dedykowanego VRAM
- Ekran górny: 400×240 pikseli (800×240 w trybie 3D stereoskopowym)
- Ekran dolny: 320×240 pikseli, dotykowy rezystancyjny
- Audio: 24 kanały DSP, wyjście stereo


