Chris Collins

Article · 3do-dev: a modern 3DO dev kit

Writing 3DO games in 2026: a 1993 console, a 1990s compiler, and a debuggable emulator

How I built a modern dev kit for the 3DO Interactive Multiplayer — Norcroft ARM C in Docker, a patched Opera emulator with a GDB server and a JSON control port, and an MCP server — and used it to ship a demo and 21 games.

· 7 min read · source

Retro computingDeveloper tools

The 3DO Interactive Multiplayer came out in 1993 and asked for $699. It had an ARM CPU when everyone else was on 68000s and MIPS, a sprite engine that thought in scaled and rotated quadrilaterals, and a real preemptive multitasking OS underneath every game. It is a lovely machine to program, and in 2026 it is surprisingly hard to get started on.

So I built 3do-dev: a dev kit that turns "write a 3DO program" into a modern loop of edit → build → boot → observe → test → debug, which works the same whether the thing at the keyboard is me, a script, or Claude Code. This is how it works and what it took.

The CLAUDE x 3DO demo running in the emulator: plasma
Figure 1. The demo: plasma, starfield, a rotating cel vortex, a bouncing title, and a sine scroller, at 60 fps on an emulated 12.5 MHz ARM60.

Why the 3DO is an interesting target

The hardware in one paragraph: an ARM60 at 12.5 MHz (big-endian ARMv3), 2 MB of DRAM plus 1 MB of VRAM, and two custom chips. MADAM drives the cel engine, which draws hardware-scaled, rotated, and blended sprites ("cels") and does matrix maths; CLIO handles video, the audio DSP, timers, and I/O. Games don’t bang the hardware directly. They run on Portfolio, a preemptive multitasking OS with tasks, message ports, semaphores, and shared libraries called folios (graphics, audio, maths, filesystem). Programs are AIF executables on a CD whose boot code is RSA-signed.

That combination — a RISC CPU, a quad-drawing GPU ancestor, and a proper OS — makes it feel much closer to modern programming than its contemporaries, while still being small enough to understand completely.

Why it is hard in 2026

Three things stand in the way.

The compiler. The only complete toolchain is ARM’s own SDT 2.51 — the Norcroft C compiler that 3DO developers actually used. trapexit’s 3do-devkit packages it with the Portfolio 2.5 SDK and modern disc tools, which is a huge head start. But the compilers are 32-bit i386 Linux binaries and the disc tools are x86-64 Linux binaries. Nothing runs them natively on a Mac, and Rosetta doesn’t do 32-bit.

The feedback loop. Emulators exist — Opera, the maintained descendant of FreeDO and 4DO, is excellent — but an emulator built for playing games gives you a window and nothing else. No debug console, no breakpoints, no way for a script to press a button and check what happened.

The debug information. Norcroft emits DWARF 2, but in ways modern gdb quietly rejects.

How the kit solves it

A 1990s compiler in a container

bin/3do-make runs make inside a tiny amd64 Debian image with the i386 C library, with the repo mounted and running as my own user ID, so generated files belong to me. On Apple Silicon the build script registers QEMU user-mode emulation for i386 and x86-64 with the kernel’s binfmt_misc, so the container’s x86 binaries just run. A build still takes about two seconds.

The shared rules in sdk/project.mk compile with armcc -g -O2 -bigend -apcs 3/32/nofp/swst, link a self-relocating AIF with armlink, stamp the 3DO header with modbin, copy in the base filesystem, and run 3dt pack to build an ISO and RSA-sign it, so the retail BIOS boots it. Then the same objects are linked a second time as an ELF with DWARF at address 0x80, so ELF addresses equal offsets into the loaded AIF. That second link is what makes symbols and gdb possible.

An emulator you can drive

The emulator is libretro Opera, compiled natively with a patch of roughly 50 lines that adds hooks for the debug console, breakpoints, watchpoints, single-step, system-call tracing, and memory access. The hooks only cost anything while they are armed.

The part I’m proudest of: stopping the CPU never disturbs emulated time. Opera emulates a frame by running the CPU in slices interleaved with scanline processing. I made that frame function resumable — when the debugger says stop, it saves its scanline and cycle position and returns early, and the next call continues the same frame exactly where it left off. From the 3DO’s point of view, no time passes while you sit at a breakpoint.

A small Python libretro frontend (ctypes, no C glue) loads the core in-process. All the heavy work stays in C; Python only runs between frames, so headless emulation still reaches about 900 frames a second — fifteen times real time. Each emulator lives in its own session process with a JSON control port, a GDB remote-protocol server, an optional window, and a registry entry so other tools can find it.

Seeing inside Portfolio

Because Portfolio loads your program wherever it finds memory, the kit finds it by searching emulated DRAM for the program’s code (AIF self-relocation replaces a BL in the header with a NOP, which confirms the match). Runtime address = load base + ELF address, and suddenly break update_plasma, read_u32 addr=s_palette, and gdb’s qOffsets all just work.

It goes further and reads the OS’s own data structures. Every Portfolio object is an item; walking the kernel’s item table gives tasks, message ports, semaphores, devices, I/O requests, screens, and a memory map of every DRAM and VRAM page by owner. The structure offsets come from a tiny 3DO program that prints offsetof() for the SDK structs, so they are right by construction. None of this needs any code on the 3DO, and it works on a crashed program.

DevBench’s Portfolio OS inspector listing tasks with state
Figure 2. DevBench’s OS inspector: the Portfolio task list, read straight out of emulated RAM.

All of that is exposed three ways: the ./3do ctl command line (about 70 commands), a browser DevBench with a REST API, and an MCP server with about 50 tools so Claude Code can build, boot, press buttons, look at the screen, read logs, set breakpoints, and run gdb on its own.

DevBench debugging Nova Defense: stopped at a breakpoint with a live system-call trace and profile
Figure 3. Debugging Nova Defense in DevBench: stopped at a breakpoint, with the live system-call trace and profiler.

A walkthrough

Installing is one line — it checks prerequisites, clones the repo, fetches the toolchain, builds the emulator, runs the test suite, and opens the demo:

curl -fsSL https://raw.githubusercontent.com/geekychris/3do-dev/main/scripts/install.sh | bash

From there, a new game is a template away:

./3do new mygame               # copy the template into projects/mygame
$EDITOR projects/mygame/src/main.c
./3do run mygame               # rebuild, boot, open a window (control :7330, gdb :2330)
./3do test mygame              # headless smoke test
./3do gdb mygame               # source-level debugging

While it runs, everything is scriptable over the control port:

./3do ctl press buttons=A
./3do ctl screenshot path=shot.png
./3do ctl break addr=update_plasma
./3do ctl watch addr=s_palette     # stop when a global changes
./3do ctl save_state name=before-boss

And gdb behaves like gdb:

(gdb) break main.c:396
(gdb) continue
Breakpoint 1, update_text (t_=1284) at src/main.c:396
(gdb) p *s_squares[0]
$1 = {ccb_Flags = 1063666704, ccb_NextPtr = 0x84670, ccb_SourcePtr = 0x88a44, ...}

The programs themselves are C89 for Norcroft: no FPU (16.16 fixed point and the operamath folio), big-endian, and graphics as a linked list of cels drawn with one DrawCels() call per frame. The demo draws about 110 cels per frame at 60 fps alongside a 64×48 per-pixel software plasma.

The hardest problems

Making gdb believe Norcroft. The DWARF was valid-ish but rejected in three silent ways, which tdo.elffix repairs after linking:

  • armlink names every loadable section after the output file, so gdb relocated code but not data — and globals were read from, and written to, the wrong addresses. Fix: rename sections by their flags to .text, .data, .bss.

  • DW_AT_stmt_list used DW_FORM_addr instead of an offset form, so gdb ignored all line-number information. Fix: patch the abbreviation to DW_FORM_data4 — same size, so nothing else moves.

  • The .debug_frame call-frame info used conventions gdb misreads, giving one-frame backtraces. Fix: hide it; gdb’s ARM prologue analyser unwinds this code correctly on its own.

Debug output that lies. The kernel writes debug text a byte at a time to a MADAM register, which the harness captures. But kprintf() only formats its first three arguments correctly: it’s a SWI, so arguments four and up land on the user stack, where the kernel never looks. I spent a while doubting my own arithmetic before working that out. The kit’s tdo_log() formats with vsprintf and passes a single string.

Cel engine details. Clear CCB_LAST on every cel except the last or the engine stops early; HDX/HDY are 12.20 fixed point while the rest are 16.16; a pixel value of 0 is transparent, so opaque black has to be 0x0001. All of these are now written down in the repo’s notes, verified in the emulator.

Results

The demo was written, built, tested, and debugged entirely through the kit. Then it got ambitious: an Amiga compatibility layer — a Paula mixer streaming to the 3DO audio folio, a C ProTracker player, and fast paths for RectFill, polygon fills, and run-length sprites — let me port eighteen Amiga games, and three Unity games were rebuilt for the cel engine: Planet Chomp, Spectral Keep, and Rolling Steel. Each has its own disc, and all of them sit on an arcade disc behind a menu.

The arcade disc’s game menu
Figure 4. The arcade disc: every game behind one menu.
Spectral Keep
Figure 5. Spectral Keep — an isometric Unity adventure, rebuilt for the 3DO’s cel engine.
Planet Chomp
Figure 6. Planet Chomp, the Unity sphere-maze game on the 3DO.
Canyon Rescue running on the 3DO through the Amiga compatibility layer
Figure 7. Canyon Rescue, ported through the Amiga layer.

The repository’s games gallery measures each one: many run their game logic at the Amiga’s 50 steps a second, with drawing rates from about 9 fps for the most demanding to around 49 for the simplest.

Next steps

The kit is new — the first commits landed this month — and the obvious next steps are about speed and polish: more fast paths in the Amiga layer for the slower ports, and more of the hardware notes turned into tests. The bigger lesson is the one I keep relearning on retro platforms: the machine is rarely the hard part. The hard part is the loop around it, and once that loop is fast and scriptable, both people and agents get a lot braver.

The code, docs, and every game are at github.com/geekychris/3do-dev.

#3do#arm#norcroft#emulator#gdb#mcp#claude-code#homebrew#docker

Related

Article ·

gitwebsite is now open source

The engine behind this site — a Rust static-site generator with a CLI, REST API, MCP server, and desktop app — is now on GitHub under the MIT licence.

Developer tools