3dslibris: Porting an ebook reader from Nintendo DS to Nintendo 3DS

In 2007, Ray Haleblian created dslibris, an EPUB reader for the Nintendo DS. It was an impressive project for its time: it parsed EPUB 2.0, rendered text with FreeType2, managed persistent preferences, and accessed DS VRAM directly. But it stalled in 2020, with no support for other formats and an unmaintained repository by 2025.

3dslibris is the port of that reader to the Nintendo 3DS as a homebrew application. But that’s exactly what it is — a port: it requires rewriting the entire graphics layer, adapting the input system, adding support for new formats, and solving memory and performance problems on a device with only 64 MB of RAM (128 MB on the New 3DS) and an ARM11 running at 268 MHz (with a second core at 134 MHz on the New 3DS).

The project is available on GitHub under GPL v2 or later (with an AGPL section for MuPDF builds). The current version is 2.0.0.

3dslibris banner — Ebook reader for Nintendo 3DS

The following modules from the original dslibris were ported with modifications:

ModuleLinesMain changes
book.cpp675Added cover fields (coverPixels, coverImagePath)
text.cpp728Rewrote BlitToFramebuffer() for rotated 90° RGB8 framebuffer
page.cpp225Rewrote DrawNumber() for dual screen with clock and % progress
epub.cpp416Added cover extraction (epub_extract_cover())
app.cpp338Completely rewritten for libctru
prefs.cpp289Added time24h field
font.cpp184Adapted paths to sdmc:/

However, all these components have been restructured and redistributed to fit the domain-based architecture of the current repository.

  1. Entry point (main.cpp) — 3DS service initialization, directory creation, App instance and main loop with error handling.

  2. Blitting to 3DS framebuffer — The DS accessed VRAM directly in RGB555 format with normal orientation. The 3DS uses a software framebuffer in RGB8 format (24 bits) rotated 90°. The BlitToFramebuffer() function converts RGB565 → RGB8 and applies rotation when copying pixels.

  3. Cover system — During OPF parsing, the cover image is located (EPUB 2: <meta name="cover">, EPUB 3: <item properties="cover-image">). It’s extracted from the ZIP, decoded with stb_image, scaled to 95×55 pixels, and stored as an RGB565 buffer.

  4. Grid library — The original DS used a vertical list. The 3DS shows a 2×3 grid with covers, titles, progress indicators, and touch + D-Pad navigation.

  5. Clock and reading percentage — At the bottom of the left screen, the current time and book reading percentage are displayed.

  6. stb_image — Sean Barrett’s public domain library embedded for JPEG and PNG decoding, configured with STBI_NO_STDIO and only the necessary decoders to minimize binary size.

ComponentLinesPurpose
Expat XML~15,000XML parsing (EPUB, preferences)
minizip~2,200Reading files inside EPUB ZIP
stb_image~8,000Image decoding for covers
libunibreak~10,000Unicode-aware line breaking (UAX #14)
utf8proc~8,000Unicode normalization and properties

The original project had all its logic crammed into a single source/core/ directory. After the refactor: project layout into domain-based modules refactor, the code was reorganized by functional domains so that each area of the project has clear responsibility:

3dslibris/
├── source/
   ├── main.cpp                        # 3DS entry point
   ├── app/                            # Main orchestrator
      └── app.cpp                     # App::Run(), mode management, job queue
   ├── library/                        # Library / book browser
      └── app_browser.cpp             # Grid view, warmup, cover cache
   ├── reader/                         # Reading view (reflowable text)
      └── app_book.cpp                # Book mode, deferred reflow
   ├── settings/                       # Preferences and fonts
      ├── app_prefs.cpp               # Settings menu
      ├── prefs.cpp                   # XML persistence
      └── font.cpp                    # FreeType font loading
   ├── book/                           # Book domain model
      ├── book.cpp                    # Metadata, pages, chapters
      ├── page.cpp                    # Individual text page
      ├── book_fixed_layout.cpp       # Viewport and deferred work (PDF/CBZ)
      ├── book_inline_image.cpp       # Inline image loading and caching
      ├── heading_layout.cpp          # Keep-with-next for headings
      ├── inline_image_layout.cpp     # Inline/band/page scheduler
      ├── layout_reflow.cpp           # Deferred repagination
      └── reflow_worker.cpp           # New 3DS worker thread
   ├── formats/                        # Format parsers
      ├── common/                     # Shared utilities
         ├── book_io.cpp             # Generic book I/O
         ├── buffered_status_log.cpp # Buffered logging
         ├── epub_image_utils.cpp    # EPUB image utilities
         ├── file_read_utils.cpp     # Safe file reading
         ├── page_cache_utils.cpp    # Persistent page cache
         └── xml_parse_utils.cpp     # Expat wrapper
      ├── epub/                       # EPUB parser
      ├── fb2/                        # FictionBook 2 parser
      ├── mobi/                       # Full MOBI parser
         ├── mobi.cpp                # Main MOBI pipeline
         ├── mobi_record_decode.cpp  # Record decoding (HUFF/CDIC)
         ├── mobi_record_scan.cpp    # Bounded record scanning
         ├── mobi_text_cleanup.cpp   # Text cleanup and wrap fix
         ├── mobi_heading_markers.cpp # Semantic heading markers
         ├── mobi_cover_meta_cache.cpp # Cover metadata cache
         ├── mobi_markup_tag.cpp     # MOBI markup tags
         └── mobi_position_map.cpp   # Position mapping
      ├── mupdf/                      # MuPDF backend (PDF/XPS)
         ├── mupdf_common.cpp        # Shared context
         ├── mupdf_document.cpp      # Document opening
         ├── mupdf_render.cpp        # Page rendering
         ├── mupdf_view.cpp          # Viewport and progressive rendering
         └── mupdf_worker.cpp        # Rendering worker thread
      ├── pdf/                        # PDF wrapper over MuPDF
      ├── cbz/                        # Comic Book Archive
         ├── cbz.cpp                 # Detection and entry
         ├── cbz_archive.cpp         # ZIP archive management
         ├── cbz_decode.cpp          # Image decoding
         ├── cbz_document.cpp        # Document initialization
         ├── cbz_view.cpp            # View and viewport
         └── cbz_worker.cpp          # Rendering worker thread
   ├── menus/                          # Paginated menus
      ├── menu.cpp                    # Menu base
      ├── chapter_menu.cpp            # Chapter index
      ├── bookmark_menu.cpp           # Bookmarks
      └── paged_list_menu.cpp         # Generic paginated list
   ├── ui/                             # UI layer
      ├── text.cpp                    # Text engine (FreeType + blit)
      ├── button.cpp                  # UI buttons
      ├── browser_nav.cpp             # Library navigation
      ├── touch_utils.cpp             # Touch hit-testing
      └── ui_button_skin.cpp          # Procedural button skin
   ├── core/                           # Minimal core
      ├── parse.cpp                   # XML parsing initialization
      └── stb_image_impl.cpp          # stb_image implementation
   └── shared/                         # Cross-cutting utilities
       ├── app_flow_utils.cpp          # Flow control between modes
       ├── utf8_utils.cpp              # UTF-8 conversion and repair
       ├── text_unicode_utils.cpp      # Unicode-aware text runs
       ├── text_layout_utils.cpp       # Text layout (pre/code, etc.)
       ├── pdf_view_utils.cpp          # PDF view helpers
       └── ...                         # (19 headers total)
├── include/                            # Headers (same structure)
├── third_party/                        # External dependencies
   ├── libunibreak/                    # UAX #14 line breaking
   └── utf8proc/                       # Unicode properties
└── tests/                              # Unit tests per module
  1. main() → init gfx, create directories, App::Run()
  2. App::Run() → load fonts, find books, parse metadata (+ extract covers), show library
  3. BROWSER mode (library/app_browser.cpp): Grid of books with covers. Touch/A opens book. SELECT/Y opens settings.
  4. BOOK mode (reader/app_book.cpp): Renders pages with FreeType. D-Pad/A/B turns pages. START returns to library.
  5. FIXED-LAYOUT mode (PDF/CBZ/XPS): MuPDF rendering with zoom, bottom screen preview, and outline navigation.
  6. PREFS mode (settings/app_prefs.cpp): Configuration buttons on the right screen. Controls on the left screen.
  7. Each frame: Text::BlitToFramebuffer() copies internal buffers to the real 3DS framebuffer.
screenleft[400×400]   — Top screen, used as 400×240
screenright[400×400]  — Bottom screen, used as 320×240
offscreen[400×400]    — Temporary buffer

The buffers are square (400×400) for compatibility with the original DS indexing. About 40% of the space is wasted, but changing it would require rewriting the entire text engine.

StageFormat
Internal bufferRGB565 (16 bits)
Real 3DS framebufferRGB8 (24 bits)
ConversionBlitToFramebuffer() does RGB565 → RGB8 + 90° rotation

The 3DS stores the framebuffer rotated 90° from how we see it. A 400×240 screen is stored as a 240×400 buffer. BlitToFramebuffer() handles this transformation pixel by pixel.

The original DS text engine worked byte by byte, which caused problems with multi-byte UTF-8 characters (accents, ñ, CJK characters). libunibreak (UAX #14 for correct line breaking) and utf8proc (Unicode properties) were integrated so the text engine understands real codepoints instead of individual bytes. This directly affects how line breaks, wrapping, and pagination are calculated in books with non-ASCII content.

A safe limit was added to the configurable text size (text_limits.h). Without this clamp, extreme font size values could cause buffer overflows and visual corruption. Limits are applied both in the preferences UI and in direct rendering.

EPUB (ZIP) → META-INF/container.xml → rootfile (.opf)
                                ┌──────┴──────┐
                                │  Metadata    │
                                │  (title,     │
                                │   author,    │
                                │   cover)     │
                                └──────┬──────┘
                                ┌──────┴──────┐
                                │  Manifest    │
                                │  (files)     │
                                └──────┬──────┘
                                ┌──────┴──────┐
                                │  Spine       │
                                │  (order)     │
                                └──────┬──────┘
                                XHTML of each chapter
                                → XML parsing → Page objects

Previously, any <img> embedded in EPUB/FB2 was treated as a full-screen block. That was too aggressive: musical lines, horizontal separators, and small icons took up an entire screen.

The current version uses a shared scheduler (inline_image_layout.cpp) between pagination and render that classifies each image into one of three modes:

  • inline: very small image, behaves like a large glyph within the line flow
  • band: wide image or block icon, consumes only the necessary height and text continues below
  • page: large image, maintains the classic full-screen block behavior

Additionally, the parser marks when an image is the first piece of a paragraph, which allows better handling of EPUBs where icons (WARNING, TECHNICAL INFO, etc.) are semantically part of the same paragraph as the text.

The first prototype of the smart layout heavily penalized opening EPUBs with many images (~50 seconds in local tests). The solution:

  • A ZIP entry index per book is maintained
  • During spine parsing, an auxiliary unzFile is reused
  • The metadata probe tries to read only a prefix of the image when sufficient

Result: the same EPUBs dropped from ~50 s to ~5 s opening time.

<pre> and <code> blocks in technical EPUBs need special treatment: text must not wrap like normal flow, but it also must not break the page buffer. A dedicated layout route was implemented that respects literal line breaks within these blocks without affecting the rest of the text flow.

FormatSupport levelEngine
EPUB (2/3)StrongCustom parser + FreeType
FB2GoodCustom XML parser
TXT / RTF / ODTGoodCustom parser
MOBIExperimentalCustom parser with inline image pipeline
PDFViewerMuPDF
CBZViewerMuPDF
XPSViewerMuPDF

The biggest new feature in version 2.0.0 is the incorporation of MuPDF as a rendering engine for fixed-layout formats. This wasn’t trivial:

MuPDF was integrated as an isolated backend (refactor: isolate fixed-layout backends) that shares the visualization infrastructure with the rest of the reader but maintains its own rendering pipeline:

  • Top screen: zoomed region of the current page
  • Bottom screen: full-page preview with viewport box
  • Unified controls: A/B zoom, Left/Right change page, Up/Down navigate outline, touch to move viewport

The stack is divided into three layers:

  1. Document (mupdf_document.cpp): opening, context, outline
  2. Render (mupdf_render.cpp): page rendering to bitmap
  3. View (mupdf_view.cpp): viewport, progressive rendering, touch interaction

Instead of waiting for a page to render completely before showing it, a preview-first pipeline is used:

  1. A low-resolution version is shown immediately
  2. It’s refined in the background at higher quality
  3. The user can interact with the page while it refines

The New Nintendo 3DS has a second processor core. PDF/CBZ rendering is delegated to a dedicated thread on the second core (reflow_worker.cpp for text, mupdf_worker.cpp / cbz_worker.cpp for fixed-layout), keeping the UI responsive. The Old 3DS uses an automatic synchronous fallback.

  • Page prefetch is deferred until the user turns a page, avoiding unnecessary work
  • Render cache reuse was accelerated by stabilizing the viewport
  • Dirty rectangles are tracked precisely to avoid unnecessary redraws
  • Fixed-layout redraw artifacts that caused flickering during page navigation were fixed

To avoid race conditions during the build, MuPDF minimal file generation was serialized in the Makefile, ensuring dependencies resolve in the correct order even with parallel compilation (make -j2).

MuPDF is distributed under AGPL-3.0-or-later. This meant:

  • Adding a dual licensing model to the repository (GPL v2+ for base code, AGPL for MuPDF builds)
  • Documenting the source release flow to comply with AGPL requirements
  • Separating release artifacts so it’s clear which build carries which obligations

MOBI is probably the most complex format to support correctly. Unlike EPUB (which is essentially a ZIP with XHTML), MOBI uses a proprietary binary format with HUFF/CDIC compression, chained records, and a TOC structure that heavily depends on how the file was generated.

The original MOBI decoder had bugs in record decoding that caused corruption in books with HUFF/CDIC compression. The decoding module (mobi_record_decode.cpp) was rewritten with a more robust approach:

  • Bounded scanning of image records to avoid uncontrolled sweeps on large books
  • Correct HUFF/CDIC table decoding
  • RGB retry for certain inline JPEGs that fail on initial decode

In version 2.0.0, MOBI opening on New 3DS is asynchronous: the user can start reading while the rest of the book is paginated in the background. This eliminates the UI blocking that could previously last several seconds on large books.

Inline images in MOBI (<img recindex="N">) use the same inline / band / page pipeline as EPUB/FB2, with:

  • RGB retry for certain JPEGs that fail on initial decode
  • Preservation of binary image tokens during text cleanup phases
  • Persistent cover metadata cache

Many poorly converted MOBI files have hard-wrapped lines (each line of prose ends with a real line break). An optional per-book fix cleans up these breaks without destroying inline image markers.

Additionally, the legacy plain-text wrap behavior for MOBI that had been lost during earlier refactorings was restored, ensuring that unmarked prose behaves correctly.

MOBI TOC resolution uses an html→text→page mapping with two lookup tables. It’s heuristic and depends on the source file, but significantly more accurate than the initial approach.

ComponentEstimated memory
Screen buffers (×3)~940 KB
FreeType fonts (×4)~800 KB
XML/EPUB buffers~200 KB
20 books with covers~200 KB
500 text pages~1 MB
Total estimated~3.1 MB

Well below the 64 MB available on an Old 3DS, leaving room for temporary buffers, caches, and the MuPDF heap.

OptimizationDetail
-O2Compilation with optimization level 2
-ffunction-sectionsDead code elimination per section
-fno-rtti -fno-exceptionsNo RTTI or C++ exceptions
-march=armv6k -mtune=mpcoreOptimized for 3DS ARM11
Lazy double bufferOnly redraws when view_dirty changes
Glyph advance cache (LRU)Glyph advance width cache to avoid repeated rasterization
EPUB page cachePersistent page cache for fast reopens

Changes to font, size, spacing, or orientation do not repaginate the open book. The setting is saved, the book is marked as outdated, and repagination is applied when reopening. This avoids inconsistent states and layout bugs.

The library (browser) received several late-stage optimizations:

  • Lazy cover loading: covers of non-visible books are not decoded until they enter the viewport
  • Selective warmup: the selected book receives warmup priority after a brief idle period
  • Job queue: metadata, cover extraction, and TOC resolution tasks are enqueued and executed with a time budget (budget_ms), avoiding frame blocking
  • Cover cache pruning: non-visible covers are freed from memory to keep the footprint low
  • Optimized framebuffer blit: copies to the 3DS framebuffer use precise dirty region tracking, avoiding unnecessary full copies

Temporary buffer creation and destruction in parsing and rendering flows was significantly reduced:

  • Page buffers are reused through an owned buffer system (page_buffer_utils.h) instead of allocating/deallocating on each parse
  • Buffer churn in book I/O flows was reduced with buffer aliasing instead of copies
  • RTF text cleanup was optimized with a dedicated control word parser (rtf_control_word_utils.h) that avoids creating intermediate strings

In debug builds, parse logging is buffered in memory (buffered_status_log.cpp) instead of writing line by line to file. This drastically reduces state I/O and prevents logging from interfering with pagination performance.

UTF-8 decoding in text rendering flows was optimized, reducing conversion cost per frame. Mojibake repair (misencoded characters) was centralized in utf8_utils.cpp with optimized routes for the most common patterns (fullwidth bytes, soft-hyphen corruption).

ArtifactPurpose
3dslibris.3dsxHomebrew Launcher
3dslibris-debug.3dsxBuild with verbose logging (3dslibris.log)
3dslibris.ciaDirect install (includes fonts and resources via romfs)
3dslibris-sdmc.zipSD package with directory structure

Originally, the .cia required the user to manually extract 3dslibris-sdmc.zip to the SD card to access fonts and UI resources. This was fixed by packaging runtime assets (font/, resources/) inside the application’s romfs. Now a plain .cia install works without additional steps.

The SD fallback still exists: if files are present at sdmc:/3ds/3dslibris/, those take priority over packaged ones, allowing customization.

The .cia packaging uses makerom and bannertool, aligned with the Universal-Updater flow. It’s built inside a Docker container to avoid permission issues:

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'

When pushing a v* tag, the release.yml workflow automatically compiles and attaches all artifacts to GitHub Releases.

MetricValue
Lines of own code (C/C++)~7,000
Vendored libraries~43,000 (Expat + minizip + stb_image + libunibreak + utf8proc)
Binary size (.3dsx)~1.44 MB
Supported formats8 (EPUB, FB2, TXT, RTF, ODT, MOBI, PDF, CBZ, XPS)
Total commits173+
Active development time~2 months intensive
Modules in shared/19 cross-cutting utility headers
Unit test suites20+ with validation scripts

Programming for the 3DS forces you to think about every byte. There’s no garbage collector, no virtual memory, no swap. If you allocate 2 MB too much, the system simply won’t boot. Every optimization needs careful thought. That includes everything from reusing ZIP state to buffering logs instead of writing line by line. Everything has a measurable impact.

Porting code from 2007 written for a completely different architecture is an exercise in software archaeology. The original dslibris assumes direct VRAM access, specifically mapped buttons, and a 256×192 screen. Adapting it to the 3DS meant understanding every implicit assumption and replacing it without breaking the core pagination logic.

The refactor from source/core/ (a monolithic directory with 20+ files) to a domain-based structure (app/, library/, reader/, book/, formats/, ui/, shared/) was one of the best decisions of the project. It not only made the code more navigable, but forced thinking about dependencies between modules and extracting cross-cutting utilities into shared/.

Integrating MuPDF forced me to understand the real implications of the AGPL. It’s not enough to say “it’s open source”: you have to document the source release, separate components, and be transparent with users about which build carries which obligations.