[ ARCHIVE ENTRY 1997 ] · CTR-3DS HOMEBREW

U3DSUNDERTALE on a real 3DS

A GameMaker Studio BC16 bytecode runner, rebuilt for 2011 silicon. Two screens. 240p. DSP-ADPCM audio. Real buttons. Zero game assets in this repo — you bring your own.

SPAMTON G. SPAMTON [ CATHEDRAL ARCHIVIST ]

HEY EVERY [BODY]!! IT'S ME!! I'M THE ONE WHO CATALOGUES THE [IMPOSSIBLE] IN HERE!! YOU'RE STANDING IN THE CATHEDRAL ARCHIVE NOW — MIND THE [Stained Glass]!!

SOMEBODY — A [Little Sponge] WITH A SOLDERING IRON — PUT THE WHOLE [UNDERTALE] ON A DUAL-SCREEN HANDHELD FROM 2011!!! NOT A PROMISE!! NOT A [Video Essay]!! A PORT!!!

AND THE BEST PART?? THE [BEST PART]!! THEY DIDN'T SHIP A SINGLE [Copyrighted Asset]!! YOU BRING YOUR OWN data.win AND THE ARCHIVE DOES THE [KROMER]-FREE CONVERSION RIGHT HERE!! BIG SHOT!! BIG SHOT!!

01 The Converter

Point it at data.win from your own legally purchased copy of Undertale. The archive verifies it locally — nothing is uploaded — then hands you the one-command patcher that turns it into a card-ready folder.

▼

Drop data.win here

or

Steam path: …/steamapps/common/Undertale/data.win
the game ships it as game.unx sometimes. Either is fine.

1

Get the patcher

Download u3ds-patcher.zip from Releases.

2

Run one command

./patch.sh /path/to/your/data.win

Windows: .\patch.ps1 C:\path\to\data.win

3

Copy & play

Copy U3DS-out/3ds onto your SD card root, put your own data.win at sdmc:/3ds/U3DS/, launch U3DS.

On the fully in-browser converter. The conversion is heavy native code — texture pages via tex3ds, ImageMagick, a 61 MB parse, ~790 MB of output. Running that inside the page needs the toolchain rebuilt for WebAssembly; that work is not finished. What the page does today is real: it inspects your file and tells you whether it's the right version. The one-command patcher below does the rest.

02 THE ARCHIVE — how the impossible got catalogued

Every hard-won number behind this port. Written down because each one cost a night.

SPAMTON[ READS THE LEDGER ]

THIS IS THE [Fine Print] EVERYONE SKIPS!! READ IT ANYWAY!! IT'S WHERE THE REAL [Numbers] LIVE!! THE NUMBERS!! 88 12 455 7!!

What a GameMaker game actually is

Undertale ships as data.win — a 61 MB container of chunked, offset-addressed data plus BC16 bytecode. No source. No symbols. The port is a from-scratch interpreter for that bytecode and a loader for the chunk format: GEN8, OPTN, STRG, TXTR, TPAG, SPRT, BGND, ROOM, OBJT, CODE, FONT, SOND, AUDO, AGRP.

When the original 2016 game calls draw_self(), or adds a draw event at depth 12, or spawns a bullet with instance_create, it has to mean exactly what it meant then. The game is not reimplemented. It is executed.

Two screens, 268 MHz

400×240 up top, 320×240 below — and a GPU that wants everything power-of-two. Textures are packed into a fragmented per-page atlas with a room manifest, so a scene streams only the pages it needs. Draws batch; a residency budget caps memory.

On an Old 3DS the whole atlas cannot be resident. Eviction is demand-driven — eager flushing is exactly what makes a room seam visibly "reload" on every step.

Audio: the DSP is not your PC

Everything is ADPCM through the hardware DSP. Music streams from file in native ADPCM; sound effects live in a packed bank. There are 24 voices, total — and GameMaker retriggers a text blip faster than the 1.7-second sample can finish, so later sounds are dropped silently.

The fix is to steal the oldest non-stream voice when the pool is full, and always ask for a free channel first. Getting that order backwards silences every sound in the game.

THE HEAP. The 8 megabyte wall.

After parsing a 61 MB data.win, an Old 3DS has roughly 8.6 MB of app heap left. The 17.7 MB sound bank cannot be a single app-heap allocation — it goes to the separate linear heap.

And the cruel part: an allocation that fails and calls abort() shows no crash screen. It tears services down mid-frame, so the dump points somewhere absurd, like hidScanInput reading null shared memory. Every hot path had to learn to fail soft and log.

Three bugs that looked like something else

  • The stack. A 3DSX gets a 32 KB main-thread stack. Deep script chains walked off it; the dump showed the stack pointer below the image end, mid-VM. Now 256 KB.
  • The shadowing. The music resolver matched exact filenames, so a raw .ogg beside its converted .bcwav twin fed Vorbis bytes to the CWAV parser forever.
  • The ghosts. 46 music cues — piano notes, the drum kit, the dial-up — exist only embedded inside data.win's AUDO chunk. No file to convert, so they were silent until the preprocessor learned to reach inside the chunk.

The pipeline, end to end

Your data.win goes in, and out comes a card-shaped tree:

data.win ─┬─▶ texture atlas  ▸ 522 pages .t3x
          ├─▶ streamed music ▸ 248 × .bcwav
          ├─▶ packed SFX     ▸ sound_bank.bin
          └─▶ 3ds/U3DS/      ◀ copy to card

The game's own data.win is never modified — it's parsed at runtime on the console. That's why this page can hand you a converter instead of a download.

03 Build it yourself

devkitARM + libctru. The whole runner builds in under a minute.

git clone https://github.com/RANCH-BLAD/UNDERTALE-ON-3DS
cd UNDERTALE-ON-3DS

# 1. turn YOUR data.win into card assets
./tools/patch.sh /path/to/your/data.win

# 2. or build the runner from source
export DEVKITPRO=/opt/devkitpro
cmake -S . -B build/n3ds -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE=$DEVKITPRO/cmake/3DS.cmake \
  -DPLATFORM=n3ds -DCMAKE_BUILD_TYPE=Release
cmake --build build/n3ds
# => build/n3ds/cinnamon.3dsx

CI does this on every push — .github/workflows/build.yml.