The Nokia 9500 has been my main phone since the 9210 started shutting itself down at random. It has a 640×200 screen, a full keyboard and a 150 MHz ARM9 running Symbian OS 7.0s with Series 80 v2.0 on top. In other words, it's a pocket computer that badly needed a NES emulator.
There wasn't a good one, so I made one: NES9500. The code was written by Claude (Anthropic's AI). I built it on my Windows 2000 VM with the S80 v2.0 SDK, tested it on the phone, sent back logs and screenshots, and complained. A week later, it plays Super Mario Bros. 3, Blaster Master and Battletoads at full speed with sound, and the whole thing fits in a 42 KB SIS file.
This post is about how it works. It gets geeky.
A core from scratch
NES9500 isn't a port. It doesn't contain code from FCEUX, Nestopia, InfoNES or anything else; every line was written for this phone. The SDK's compiler is GCC 2.95, so everything is C89, with declarations at the top of each block like it's 1999. That's fitting, since it nearly is.
| File | Lines | What it does |
|---|---|---|
cpu.c | 412 | 6502 interpreter: one big switch over all 256 opcodes, unofficial ones included |
ppu.c | 637 | Scanline renderer using loopy's scroll model, writing straight into the framebuffer, already scaled |
apu.c | 539 | Two pulses, triangle, noise and DMC, non-linear mixing, 22050 Hz mono |
nes.c | 569 | Memory map, iNES loader, mappers, frame loop, save states |
nesmain.c | 1253 | ROM list, menus, settings, key config, frame pacing |
epocsys.cpp | 971 | Native Symbian layer: framebuffer, keys, menus, files |
epocaudio.cpp | 345 | Audio thread feeding CMdaAudioOutputStream |
The native layer comes from my earlier Doom9500 and Quake9500 ports. It skips the Symbian UI framework entirely: it grabs the screen's raw memory address through the HAL (RGB565, 1280 bytes per line), puts its own window group in front so the system keeps quiet, and draws there directly. Menus are drawn with the system font straight into the framebuffer, so the emulator carries no font data at all.
Supported mappers: 0 (NROM), 1 (MMC1), 2 (UxROM), 3 (CNROM), 4 (MMC3), 7 (AxROM), 11 (Color Dreams), 66 (GxROM) and 71 (Camerica). That covers SMB 1–3, both Zeldas, Metroid, Mega Man 1–6, Castlevania 1–2, Contra, Kirby, Battletoads, Duck Tales and a few hundred others.
SMB3 uses MMC3, the mapper with a scanline counter that games use for split screens, like the status bar at the bottom here.
Squeezing 240 lines into 200
The NES draws 256×240, and a TV shows roughly lines 8–231. The 9500 has 200 rows, which doesn't fit. There are two options in the menu:
- Fit all 224 (the default) drops every ninth line. You see the whole picture, slightly squashed. A precomputed table maps each NES line to a screen row or to −1, and lines that map to −1 aren't drawn at all.
- Crop to 200 shows the middle 200 lines 1:1. Text is sharper, but status bars at the edges get cut.
Left: fit. Right: crop. Look at the WORLD 1 text in the status bar: crop keeps it sharp but cuts off the bottom of the bar.
Horizontally there are four sizes: 1× (256 px, the true shape when combined with "fit"), 1.5× (384, the default), 2× (512) and full width (640). Scaling happens while each line is written out, so there's no second pass.
The same frame at 1×, 1.5×, 2× and full width.
Whatever the picture doesn't cover gets a wallpaper: background.bmp in the NES folder, or <game>.bmp next to a ROM to give that game its own. The decoder handles uncompressed 1/4/8/24/32-bit BMPs, so whatever Paint saves works. A 5-pixel black border keeps the game picture from bleeding into the wallpaper.
My wallpaper: a living room with a CRT, a lamp and too many VHS tapes. At 1.5× the game covers the TV and the shelves frame it.
The speed hunt
The first build ran straight away and played Blaster Master with sound, which genuinely surprised me. It wasn't fast enough, though, and we went through three rounds of optimisation, each one driven by numbers from the phone's log file.
Round 1 wrote two pixels per 32-bit store, built a per-line sprite list only when OAM changes instead of scanning all 64 sprites on every line, and folded the NMI into the IRQ word so the CPU checks one variable per instruction instead of two. Result: 58–62 fps emulated, about 36 frames drawn per second.
Round 2 went after the CPU. Every opcode fetch now goes through a cached window onto the current ROM bank:
#define FETCH(a) ({ \
const u32 fa_ = (a); \
u32 fv_; \
if (fa_ - pcwin < 0x2000) \
fv_ = pcbase[fa_]; \
else if (fa_ >= 0x8000) \
{ \
pcwin = fa_ & 0xe000; \
pcbase = cart.prgbank[(fa_ >> 13) & 3] - pcwin; \
fv_ = pcbase[fa_]; \
} \
else \
fv_ = nes_read(fa_); \
fv_; })
The unsigned subtraction folds both bounds checks into one compare, and pcbase is pre-offset so the hit path is a single load. It's a GCC statement expression, which GCC 2.95 already supported. Round 2 also added a log line that turned out to be the most useful thing in the project:
Frame time: drawn 21-29 ms, skipped 0.2-4 ms
A frame where the picture is drawn costs about 25 ms. A frame where it's skipped costs a couple. So the 6502 is cheap; the time goes into drawing. The CPU work had been aimed at the wrong target all along.
Round 3 therefore rebuilt the drawing. The PPU no longer draws a line whenever the CPU crosses a scanline. It catches up lazily: nes_frame() only records how far the beam has got, and lines are drawn in a batch when something visible is about to change (a write to a PPU register, a mapper bank switch, OAM DMA) or at the end of the frame:
#define PPU_SYNC() do { if (ppu_done <= ppu_now && ppu_done < 240) ppu_sync(); } while (0)
Most games touch the PPU only during vblank, so most frames are drawn in one tight loop that doesn't fight the CPU emulation for the 9500's small cache. Each line is built in a cached buffer and then blasted to screen memory four words at a time, because the 9500's screen memory is slow to write and much prefers burst stores:
"1:\n\t"
"ldmia %1!, {r4, r5, r6, r7}\n\t"
"stmia %0!, {r4, r5, r6, r7}\n\t"
"subs %2, %2, #1\n\t"
"bne 1b\n\t"
Games always run at the correct 60 Hz (or 50 for PAL), and automatic frameskip decides how many frames are actually drawn. Blaster Master typically draws 30–45 frames per second and Battletoads 23–41, and it feels smooth. The emulator sleeps only when it's a whole 16 ms system tick ahead, since Symbian's timer can't do anything finer.
A Symbian bug found along the way
The input code polls a window server event request (iEventStatus) instead of blocking on it. It turns out that if you check the status without calling User::WaitForRequest, the completion signal stays on the thread's semaphore. They pile up, and every later wait in the program spins straight through them. NES9500 now consumes the signal properly. Doom9500 and Quake9500 still poll the old way, which may explain why they get sluggish after a while.
Sound
The APU runs in step with the CPU and writes 22050 Hz mono samples into a ring buffer. A separate audio thread pulls from the ring into CMdaAudioOutputStream. The first test logged 1 underflow in 4617 chunks. The channels are mixed with the NES's non-linear lookup tables, and there's a DC filter and a low-pass, so it sounds like a NES and not like five square waves added together.
The volume has 8 steps that halve at the quiet end: 1, 2, 4, 8, 16, 32, 48 and 64 out of 64, roughly 6 dB apart. The original four steps bottomed out at 25%, and at 25% the 9500's speaker makes it clear to everyone on the bus that you're playing Mario.
PAL, NTSC and Finnish ROM dumps
I'm in Finland, so a lot of my carts are PAL. PAL runs 312 lines per frame, 106.5625 CPU cycles per line, and has its own APU period tables. NES9500 picks the region from the iNES header or from the file name: (E), (Europe), (Scandinavia) and so on mean PAL. You can override it in the menu.
Save states the lazy way
Everything the emulated machine knows lives in one struct, NesState, with no pointers in it: CPU, PPU, APU, mapper registers, RAM, SRAM and CHR RAM. A save state is a memcpy. After loading one, mapper_sync() rebuilds the bank pointers from the mapper registers. Chr+S saves, Chr+L loads, and a small square flashes in the corner (green for saved, blue for loaded, red for no state). Chr+F toggles fast forward.
Keys, turbo, and 16 sprites per line
The Communicator keyboard has plenty of keys. The defaults are WASD, the arrow keys or the rocker for the D-pad, Z/K/Ctrl/rocker press for A, X/L for B, Enter or Space for Start, and Tab or Shift for Select. I and O are turbo A and B, with fast, medium and slow speeds (30, 15 or 10 presses per second).
Every button can be remapped from the menu. The emulator keeps a 256-entry table from hardware scan code to pad bits, and turbo uses bits 0x100 and 0x200, above the eight real NES buttons, so they get masked off before the game sees them. Remapping waits for the key to be released before returning, so the key you just assigned doesn't fire into the game. The table is saved in nes9500.cfg as plain scancode=bits pairs.
A real NES can show 8 sprites per line, and games flicker sprites to get around that. NES9500 shows up to 16 by default, which removes most of the flicker. "Sprites per line: 8" restores the real behaviour for the few games that deliberately hide things behind the limit.
The feature that didn't make it
The 9500's screen is wide, and a NES nametable holds more picture than the 256 pixels you see. So we tried a "wide view" that drew 64 extra background pixels on each side from the offscreen part of the nametable.
It failed for a fundamental reason: games write each new column of scenery into the nametable just before it scrolls into view. The area beyond the visible edge isn't the upcoming level; it's leftovers. In Blaster Master the extra columns flickered and showed scenery from random places. After a couple of fixes that made it only somewhat less bad, I removed the whole thing.
A real screenshot from the phone: Blaster Master, taken while chasing the wide-view bug. The other images in this post come from the same emulator core running on a PC with the phone's 640×200 layout, because it's easier to get the timing right with scripted input.
The launcher and the icon
The emulator itself is a plain .exe. To get an icon on the Desk, there's a tiny launcher app. It draws a black screen, starts nes9500.exe from a CIdle callback right after startup, and exits. If the emulator can't be found, it stays open with an error and Start/Exit buttons.
Something worth knowing if you make S80 v2 apps: the AIF Builder offers 24×24, 32×32 and 48×48 icons, but the 9500 actually uses 64×50 and 25×20. I checked the AIF files of the built-in apps: 33 have a 25×20 icon and 24 have a 64×50 one. You can skip the AIF Builder entirely and put the icons on the AIF line in the .mmp:
AIF NES9500.aif . NES9500Aif.rss c16,1 appicon_64x50.bmp appicon_64x50_mask.bmp appicon_25x20.bmp appicon_25x20_mask.bmp
In the masks, black means icon and white means transparent. I drew the icon with green corner pixels to mark where it should be transparent, and a small Python script turns exactly that green into the mask. The black tile stays, with rounded corners.
What's missing
- MMC2/MMC4 (Punch-Out!!, Fire Emblem)
- MMC5 (Castlevania III)
- The Konami VRC mappers and the Famicom Disk System
- ARM assembly for the 6502 core, if I ever want more speed. Honestly, it's already fine.
Games go in D:\NES\ on the memory card (or C:\NES\). Battery saves and save states sit next to each ROM, and nes9500log.txt records what the emulator thinks of your phone, including the 6502 program counter if something crashes.
Not bad for a 2004 phone with a 150 MHz CPU. Next up: Punch-Out!!.






