3dslibris: Portar un lector de ebooks de Nintendo DS a Nintendo 3DS

En 2007, Ray Haleblian creó dslibris, un lector de EPUB para la Nintendo DS. Era un proyecto impresionante para su época: parseaba EPUB 2.0, renderizaba texto con FreeType2, gestionaba preferencias persistentes y usaba directamente la VRAM del DS. Pero se quedó estancado en 2020, sin soporte para otros formatos y con el repositorio sin mantenimiento en 2025.

3dslibris es el port de ese lector a la Nintendo 3DS como aplicación homebrew. Pero es eso, un port: implica reescribir la capa gráfica completa, adaptar el sistema de input, añadir soporte para nuevos formatos, y resolver problemas de memoria y rendimiento en un dispositivo con solo 64 MB de RAM (128MB en el caso de la New 3DS) y un ARM11 a 268 MHz (con un segundo núcleo a 134 MHz en la New 3DS).

El proyecto está disponible en GitHub bajo licencia GPL v2 o posterior (con una sección AGPL para builds con MuPDF). La versión actual es la 2.0.0.

Banner de 3dslibris — Lector de ebooks para Nintendo 3DS

Los siguientes módulos del dslibris original se portaron con modificaciones:

MóduloLíneasCambios principales
book.cpp675Añadidos campos de portada (coverPixels, coverImagePath)
text.cpp728Reescrito BlitToFramebuffer() para framebuffer RGB8 rotado 90°
page.cpp225Reescrito DrawNumber() para doble pantalla con reloj y % de progreso
epub.cpp416Añadida extracción de portadas (epub_extract_cover())
app.cpp338Reescrito completamente para libctru
prefs.cpp289Añadido campo time24h
font.cpp184Adaptados paths a sdmc:/

No obstante, todos estos componentes han sido reestructurados y redistribuidos para adaptarse a la arquitectura por dominios del repositorio actual.

  1. Entry point (main.cpp) — Inicialización de servicios 3DS, creación de directorios, instancia de App y bucle principal con manejo de errores.

  2. Blitting a framebuffer 3DS — El DS accedía directamente a VRAM en formato RGB555 con orientación normal. La 3DS usa un framebuffer software en formato RGB8 (24 bits) rotado 90°. La función BlitToFramebuffer() convierte RGB565 → RGB8 y aplica la rotación al copiar píxeles.

  3. Sistema de portadas — Durante el parseo del OPF se busca la imagen de portada (EPUB 2: <meta name="cover">, EPUB 3: <item properties="cover-image">). Se extrae del ZIP, se decodifica con stb_image, se escala a 95×55 píxeles y se almacena como buffer RGB565.

  4. Biblioteca en cuadrícula — El DS original usaba una lista vertical. La 3DS muestra una cuadrícula de 2×3 con portadas, títulos, indicadores de progreso y navegación táctil + D-Pad.

  5. Reloj y porcentaje de lectura — En la parte inferior de la pantalla izquierda se muestra la hora actual y el porcentaje de lectura del libro.

  6. stb_image — Librería de Sean Barrett embebida para decodificar JPEG y PNG, configurada con STBI_NO_STDIO y solo los decoders necesarios para minimizar el binario.

ComponenteLíneasPropósito
Expat XML~15.000Parseo XML (EPUB, preferencias)
minizip~2.200Lectura de archivos dentro del ZIP del EPUB
stb_image~8.000Decodificación de imágenes para portadas
libunibreak~10.000Line breaking Unicode-aware (UAX #14)
utf8proc~8.000Normalización y propiedades Unicode

El proyecto original tenía toda la lógica metida en un directorio source/core/. Tras el refactor refactor: project layout into domain-based modules, el código se reorganizó por dominios funcionales para que cada zona del proyecto tenga responsabilidad clara:

3dslibris/
├── source/
│   ├── main.cpp                        # Entry point 3DS
│   ├── app/                            # Orquestador principal
│   │   └── app.cpp                     # App::Run(), gestión de modos, job queue
│   ├── library/                        # Biblioteca / navegador de libros
│   │   └── app_browser.cpp             # Grid view, warmup, cover cache
│   ├── reader/                         # Vista de lectura (texto reflowable)
│   │   └── app_book.cpp                # Book mode, deferred reflow
│   ├── settings/                       # Preferencias y fuentes
│   │   ├── app_prefs.cpp               # Menú de ajustes
│   │   ├── prefs.cpp                   # Persistencia XML
│   │   └── font.cpp                    # Carga de fuentes FreeType
│   ├── book/                           # Modelo de dominio del libro
│   │   ├── book.cpp                    # Metadata, páginas, capítulos
│   │   ├── page.cpp                    # Página individual de texto
│   │   ├── book_fixed_layout.cpp       # Viewport y deferred work (PDF/CBZ)
│   │   ├── book_inline_image.cpp       # Carga y cache de imágenes inline
│   │   ├── heading_layout.cpp          # Keep-with-next para headings
│   │   ├── inline_image_layout.cpp     # Planificador inline/band/page
│   │   ├── layout_reflow.cpp           # Repaginación diferida
│   │   └── reflow_worker.cpp           # Worker thread New 3DS
│   ├── formats/                        # Parsers por formato
│   │   ├── common/                     # Utilidades compartidas
│   │   │   ├── book_io.cpp             # I/O genérica de libros
│   │   │   ├── buffered_status_log.cpp # Logging bufferizado
│   │   │   ├── epub_image_utils.cpp    # Utilidades de imagen EPUB
│   │   │   ├── file_read_utils.cpp     # Lectura segura de archivos
│   │   │   ├── page_cache_utils.cpp    # Cache persistente de páginas
│   │   │   └── xml_parse_utils.cpp     # Wrapper de Expat
│   │   ├── epub/                       # Parser EPUB
│   │   ├── fb2/                        # Parser FictionBook 2
│   │   ├── mobi/                       # Parser MOBI completo
│   │   │   ├── mobi.cpp                # Pipeline principal MOBI
│   │   │   ├── mobi_record_decode.cpp  # Decodificación de records (HUFF/CDIC)
│   │   │   ├── mobi_record_scan.cpp    # Escaneo acotado de records
│   │   │   ├── mobi_text_cleanup.cpp   # Limpieza de texto y wrap fix
│   │   │   ├── mobi_heading_markers.cpp # Marcado semántico de headings
│   │   │   ├── mobi_cover_meta_cache.cpp # Cache de metadatos de portada
│   │   │   ├── mobi_markup_tag.cpp     # Tags de markup MOBI
│   │   │   └── mobi_position_map.cpp   # Mapeo de posiciones
│   │   ├── mupdf/                      # Backend MuPDF (PDF/XPS)
│   │   │   ├── mupdf_common.cpp        # Contexto compartido
│   │   │   ├── mupdf_document.cpp      # Apertura de documento
│   │   │   ├── mupdf_render.cpp        # Renderizado de página
│   │   │   ├── mupdf_view.cpp          # Viewport y progressive rendering
│   │   │   └── mupdf_worker.cpp        # Worker thread de renderizado
│   │   ├── pdf/                        # Wrapper PDF sobre MuPDF
│   │   ├── cbz/                        # Comic Book Archive
│   │   │   ├── cbz.cpp                 # Detección y entrada
│   │   │   ├── cbz_archive.cpp         # Gestión del archivo ZIP
│   │   │   ├── cbz_decode.cpp          # Decodificación de imágenes
│   │   │   ├── cbz_document.cpp        # Inicialización de documento
│   │   │   ├── cbz_view.cpp            # Vista y viewport
│   │   │   └── cbz_worker.cpp          # Worker thread de renderizado
│   │   └── common/                     # (ver arriba)
│   ├── menus/                          # Menús paginados
│   │   ├── menu.cpp                    # Base de menú
│   │   ├── chapter_menu.cpp            # Índice de capítulos
│   │   ├── bookmark_menu.cpp           # Marcadores
│   │   └── paged_list_menu.cpp         # Lista paginada genérica
│   ├── ui/                             # Capa de interfaz
│   │   ├── text.cpp                    # Motor de texto (FreeType + blit)
│   │   ├── button.cpp                  # Botones UI
│   │   ├── browser_nav.cpp             # Navegación de biblioteca
│   │   ├── touch_utils.cpp             # Hit-testing táctil
│   │   └── ui_button_skin.cpp          # Skin procedural de botones
│   ├── core/                           # Core mínimo
│   │   ├── parse.cpp                   # Inicialización de parseo XML
│   │   └── stb_image_impl.cpp          # Implementación stb_image
│   └── shared/                         # Utilidades transversales
│       ├── app_flow_utils.cpp          # Control de flujo entre modos
│       ├── utf8_utils.cpp              # Conversión y reparación UTF-8
│       ├── text_unicode_utils.cpp      # Text runs Unicode-aware
│       ├── text_layout_utils.cpp       # Layout de texto (pre/code, etc.)
│       ├── pdf_view_utils.cpp          # Helpers de vista PDF
│       └── ...                         # (19 headers en total)
├── include/                            # Headers (misma estructura)
├── third_party/                        # Dependencias externas
│   ├── libunibreak/                    # UAX #14 line breaking
│   └── utf8proc/                       # Unicode properties
└── tests/                              # Tests unitarios por módulo
  1. main() → init gfx, crear directorios, App::Run()
  2. App::Run() → cargar fuentes, encontrar libros, parsear metadatos (+ extraer portadas), mostrar biblioteca
  3. Modo BROWSER (library/app_browser.cpp): Cuadrícula de libros con portadas. Touch/A abre libro. SELECT/Y abre settings.
  4. Modo BOOK (reader/app_book.cpp): Renderiza páginas con FreeType. D-Pad/A/B pasa páginas. START vuelve a biblioteca.
  5. Modo FIXED-LAYOUT (PDF/CBZ/XPS): Renderizado vía MuPDF con zoom, preview en pantalla inferior y navegación por outline.
  6. Modo PREFS (settings/app_prefs.cpp): Botones de configuración en pantalla derecha. Controles en pantalla izquierda.
  7. Cada frame: Text::BlitToFramebuffer() copia los buffers internos al framebuffer real de la 3DS.
screenleft[400×400]   — Pantalla superior (top), usada como 400×240
screenright[400×400]  — Pantalla inferior (bottom), usada como 320×240
offscreen[400×400]    — Buffer temporal

Los buffers son cuadrados (400×400) por compatibilidad con el indexado del DS original. Se desperdicia ~40% del espacio, pero cambiarlo requeriría reescribir todo el motor de texto (que asume buffers cuadrados) y el sistema de blitting. Es algo que tocará hacer en el futuro.

EtapaFormato
Buffer internoRGB565 (16 bits)
Framebuffer 3DS realRGB8 (24 bits)
ConversiónBlitToFramebuffer() hace RGB565 → RGB8 + rotación 90°

La 3DS almacena el framebuffer rotado 90° respecto a cómo lo vemos. Una pantalla de 400×240 se almacena como un buffer de 240×400. BlitToFramebuffer() maneja esta transformación píxel a píxel.

El motor de texto original del DS trabajaba byte a byte, lo que causaba problemas con caracteres multi-byte en UTF-8 (acentos, eñes, caracteres CJK). Se integraron tanto libunibreak (UAX #14 para line breaking correcto) como utf8proc (propiedades Unicode) para que el motor de texto entienda codepoints reales en vez de bytes individuales. Esto afecta directamente a cómo se calculan los saltos de línea, el wrapping y la paginación en libros con contenido no-ASCII.

Se añadió un límite seguro al tamaño de texto configurable (text_limits.h). Sin este clamp, valores extremos de tamaño de fuente podían causar desbordamientos de buffer y corrupción visual (como pasaba en builds antiguas). Los límites se aplican tanto en la UI de preferencias como en el renderizado directo.

FormatoNivel de soporteMotor
EPUB (2/3)FuerteParser propio + FreeType
FB2BuenoParser XML propio
TXT / RTF / ODTBuenoParser propio
MOBIExperimentalParser propio con pipeline de imágenes inline
PDFViewerMuPDF
CBZViewerMuPDF
XPSViewerMuPDF
EPUB (ZIP) → META-INF/container.xml → rootfile (.opf)
                                ┌──────┴──────┐
                                │  Metadata    │
                                │  (título,    │
                                │   autor,     │
                                │   portada)   │
                                └──────┬──────┘
                                ┌──────┴──────┐
                                │  Manifest    │
                                │  (archivos)  │
                                └──────┬──────┘
                                ┌──────┴──────┐
                                │  Spine       │
                                │  (orden)     │
                                └──────┬──────┘
                                XHTML de cada capítulo
                                → parseo XML → Page objects

Antes, cualquier <img> embebida en EPUB/FB2 se trataba como bloque de pantalla completa. Eso era demasiado agresivo: líneas musicales, separadores horizontales e iconos pequeños ocupaban una pantalla entera.

La versión actual usa un planificador compartido (inline_image_layout.cpp) entre paginación y render que clasifica cada imagen en uno de tres modos:

  • inline: imagen muy pequeña, se comporta como un glifo grande dentro del flujo de línea
  • band: imagen ancha o icono de bloque, consume sólo la altura necesaria y el texto sigue debajo
  • page: imagen grande, mantiene el comportamiento clásico de bloque a pantalla completa

Además, el parser marca cuándo una imagen es la primera pieza de un párrafo, lo que permite tratar mejor EPUBs donde los iconos (ADVERTENCIA, INFORMACIÓN TÉCNICA, etc.) forman parte semántica del mismo párrafo que el texto.

El primer prototipo del layout inteligente penalizaba mucho la apertura de EPUBs con muchas imágenes (~50 segundos en pruebas locales). La solución:

  • Se mantiene un índice de entradas ZIP por libro
  • Durante el parseo del spine se reutiliza un unzFile auxiliar
  • La sonda de metadatos intenta leer sólo un prefijo de la imagen cuando es suficiente

Resultado: los mismos EPUBs bajaron de ~50 s a ~5 s de apertura.

Los bloques <pre> y <code> en EPUBs técnicos necesitan un tratamiento especial: el texto no debe hacer wrap como el flujo normal, pero tampoco debe romper el buffer de página. Se implementó una ruta de layout dedicada que respeta los saltos de línea literales dentro de estos bloques sin afectar al resto del flujo de texto.

La mayor novedad de la versión 2.0.0 es la incorporación de MuPDF como motor de renderizado para formatos de layout fijo. Esto no fue trivial:

MuPDF se integró como un backend aislado (refactor: isolate fixed-layout backends) que comparte la infraestructura de visualización con el resto del lector pero mantiene su propio pipeline de renderizado:

  • Pantalla superior: región zoom de la página actual
  • Pantalla inferior: preview de página completa con caja de viewport
  • Controles unificados: A/B zoom, Left/Right cambiar página, Up/Down navegar outline, touch para mover viewport

El stack se divide en tres capas:

  1. Document (mupdf_document.cpp): apertura, contexto, outline
  2. Render (mupdf_render.cpp): renderizado de página a bitmap
  3. View (mupdf_view.cpp): viewport, progressive rendering, interacción táctil

En lugar de esperar a que una página se renderice completamente antes de mostrarla, se usa un pipeline de preview-first:

  1. Se muestra inmediatamente una versión de baja resolución
  2. Se refina en background a mayor calidad
  3. El usuario puede interactuar con la página mientras se refina

La New Nintendo 3DS tiene un segundo núcleo de procesador. El renderizado de PDF/CBZ se delega a un hilo dedicado en el segundo core (reflow_worker.cpp para texto, mupdf_worker.cpp / cbz_worker.cpp para fixed-layout), manteniendo la UI responsive. La Old 3DS usa un fallback síncrono automático.

  • El prefetch de páginas se difiere hasta que el usuario cambia de página, evitando trabajo innecesario
  • Se aceleró la reutilización de caché de renderizado estabilizando el viewport
  • Se trackean dirty rectangles con precisión para evitar redraws innecesarios
  • Se corrigieron artefactos de redraw en fixed-layout que causaban parpadeo al navegar entre páginas

Para evitar condiciones de carrera durante el build, la generación de los archivos mínimos de MuPDF se serializó en el Makefile, asegurando que las dependencias se resuelvan en orden correcto incluso con compilación paralela (make -j2).

MuPDF se distribuye bajo AGPL-3.0-or-later. Esto implicó:

  • Añadir un modelo de licencia dual al repositorio (GPL v2+ para el código base, AGPL para builds con MuPDF)
  • Documentar el flujo de source release para cumplir con los requisitos de la AGPL
  • Separar los artefactos de release para que quede claro qué build lleva qué licencia

MOBI es probablemente el formato más complejo de soportar correctamente. A diferencia de EPUB (que es esencialmente un ZIP con XHTML), MOBI usa un formato binario propietario con compresión HUFF/CDIC, records encadenados y una estructura de TOC que depende enormemente de cómo se generó el archivo.

El decoder MOBI original tenía bugs en la decodificación de records que causaban corrupción en libros con compresión HUFF/CDIC. Se reescribió el módulo de decodificación (mobi_record_decode.cpp) con un enfoque más robusto:

  • Escaneo acotado de records de imagen para no barrer sin control libros grandes
  • Decodificación correcta de tablas HUFF/CDIC
  • Reintento RGB para ciertos JPEG inline que fallan en el decode inicial

En la versión 2.0.0, la apertura de MOBI en New 3DS es asíncrona: el usuario puede empezar a leer mientras el resto del libro se pagina en background. Esto elimina el bloqueo de UI que antes podía durar varios segundos en libros grandes.

Las imágenes inline en MOBI (<img recindex="N">) usan el mismo pipeline inline / band / page que EPUB/FB2, con:

  • Reintento RGB para ciertos JPEG que fallan en decode inicial
  • Preservación de tokens binarios de imagen durante las fases de cleanup de texto
  • Caché persistente de metadatos de portada

Muchos MOBI mal convertidos tienen hard-wrap de líneas (cada línea de prosa termina con un salto de línea real). Se implementó un fix opcional por libro que limpia estos saltos sin destruir los marcadores de imagen inline.

Además, se restauró el comportamiento legacy de plain-text wrap para MOBI que se había perdido durante refactorizaciones anteriores, asegurando que la prosa sin markup se comporte correctamente.

La resolución del TOC en MOBI usa un mapeo html→texto→página con dos tablas de lookup. Es heurístico y depende del archivo fuente, pero bastante más preciso que la aproximación inicial.

ComponenteMemoria estimada
Screen buffers (×3)~940 KB
Fuentes FreeType (×4)~800 KB
XML/EPUB buffers~200 KB
20 libros con portada~200 KB
500 páginas de texto~1 MB
Total estimado~3.1 MB

Muy por debajo de los 64 MB disponibles en una Old 3DS, lo que deja margen para buffers temporales, cachés y el heap de MuPDF.

OptimizaciónDetalle
-O2Compilación con optimización nivel 2
-ffunction-sectionsDead code elimination por secciones
-fno-rtti -fno-exceptionsSin RTTI ni excepciones C++
-march=armv6k -mtune=mpcoreOptimizado para ARM11 de la 3DS
Doble buffer lazySolo se redibuja cuando view_dirty cambia
Glyph advance cache (LRU)Cache de avance de glifos para evitar rasterización repetida
EPUB page cacheCache persistente de páginas para reaperturas rápidas

Cambios de fuente, tamaño, espaciado u orientación no repaginan el libro en caliente. El ajuste se guarda, el libro queda marcado como desactualizado, y la repaginación se aplica al reabrirlo. Esto evita estados inconsistentes y fallos de maquetación.

La biblioteca (browser) recibió varias optimizaciones tardías:

  • Carga lazy de portadas: las portadas de libros no visibles no se decodifican hasta que entran en viewport
  • Warmup selectivo: el libro seleccionado recibe prioridad de warmup tras un período breve de inactividad
  • Queue de jobs: las tareas de metadata, extracción de portada y resolución de TOC se encolan y ejecutan con presupuesto de tiempo (budget_ms), evitando bloquear el frame
  • Poda de caché de portadas: las portadas no visibles se liberan de memoria para mantener el footprint bajo
  • Framebuffer blit optimizado: las copias al framebuffer de la 3DS se hacen con tracking preciso de regiones dirty, evitando copias completas innecesarias

Se redujo significativamente la creación y destrucción de buffers temporales en los flujos de parseo y render:

  • Los buffers de página se reutilizan mediante un sistema de owned buffers (page_buffer_utils.h) en vez de allocar/deallocar en cada parseo
  • El churn de buffers en el flujo de I/O de libros se redujo con aliasing de buffers en vez de copias
  • La limpieza de texto RTF se optimizó con un parser de control words dedicado (rtf_control_word_utils.h) que evita crear strings intermedios

En builds de depuración, el logging de parseo se bufferiza en memoria (buffered_status_log.cpp) en vez de escribir línea a línea a fichero. Esto reduce drásticamente la I/O de estado y evita que el logging interfiera con el rendimiento de la paginación.

Se optimizó el decoding de UTF-8 en los flujos de renderizado de texto, reduciendo el coste de conversión en cada frame. La reparación de mojibake (caracteres mal codificados) se centralizó en utf8_utils.cpp con rutas optimizadas para los patrones más comunes (fullwidth bytes, soft-hyphen corruption).

ArtefactoPropósito
3dslibris.3dsxHomebrew Launcher
3dslibris-debug.3dsxBuild con logging verbose (3dslibris.log)
3dslibris.ciaInstalable directo (incluye fonts y recursos via romfs)
3dslibris-sdmc.zipPaquete SD con estructura de directorios

Originalmente, el .cia requería que el usuario extrajera manualmente 3dslibris-sdmc.zip en la SD para tener acceso a fuentes y recursos de la UI. Esto se corrigió empaquetando los assets de runtime (font/, resources/) dentro del romfs de la aplicación. Ahora un .cia install funciona sin pasos adicionales.

El fallback a SD sigue existiendo: si hay archivos en sdmc:/3ds/3dslibris/, esos tienen prioridad sobre los empaquetados, permitiendo personalización.

El empaquetado .cia usa makerom y bannertool, alineado con el flujo de Universal-Updater. Se construye dentro de un contenedor Docker para evitar problemas de permisos:

docker build -f docker/Dockerfile.cia -t 3dslibris-build .

docker run --rm \
  -v "$(pwd):/project" -w /project \
  -e DEVKITPRO=/opt/devkitpro \
  -e DEVKITARM=/opt/devkitpro/devkitARM \
  3dslibris-build \
  sh -lc 'make clean && make -j2 && make zip-sdmc && make debug-3dsx && make cia && make source-release'

Al hacer push de un tag v*, el workflow release.yml compila y adjunta automáticamente todos los artefactos en GitHub Releases.

MétricaValor
Líneas de código propio (C/C++)~7.000
Librerías vendorizadas~43.000 (Expat + minizip + stb_image + libunibreak + utf8proc)
Tamaño del binario (.3dsx)~1.44 MB
Formatos soportados8 (EPUB, FB2, TXT, RTF, ODT, MOBI, PDF, CBZ, XPS)
Commits totales173+
Tiempo de desarrollo activo~2 meses intensivos
Módulos en shared/19 headers de utilidades transversales
Tests unitarios20+ suites con scripts de validación

Programar para la 3DS te obliga a pensar en cada byte. No hay garbage collector, no hay memoria virtual, no hay swap. Si allocas 2 MB de más, el sistema simplemente no arranca. Cada optimización hay que pensarla. Eso incluye desde reutilizar el estado del ZIP hasta bufferizar logs en vez de escribir línea a línea. Todo tiene un impacto medible.

Portar código de 2007 escrito para una arquitectura completamente diferente es un ejercicio de arqueología software. El dslibris original asume VRAM directa, botones mapeados de forma específica y una pantalla de 256×192. Adaptarlo a la 3DS implicó entender cada asunción implícita y reemplazarla sin romper la lógica central de paginación.

El refactor de source/core/ (un directorio monolítico con 20+ archivos) a una estructura por dominios (app/, library/, reader/, book/, formats/, ui/, shared/) fue una de las mejores decisiones del proyecto. No solo hizo el código más navegable, sino que forzó a pensar en las dependencias entre módulos y a extraer utilidades transversales a shared/.

Integrar MuPDF me obligó a entender las implicaciones reales de la AGPL. No basta con decir “es open source”: hay que documentar el source release, separar los componentes, y ser transparente con los usuarios sobre qué build lleva qué obligaciones.