lauantai 7. marraskuuta 2026

Duke9500: Duke Nukem 3D on a Communicator, AdLib music included

After Doom9500 and Quake9500 the obvious next question was one line long: "Duke Nukem 3D for Nokia 9500. Doable?"

It was. Duke9500 is the game on the Communicator's 640×200 screen, with sound effects, the original music and a face on the Desk. As before, the code was written by Claude (Anthropic's AI). I built it on my Windows 2000 VM, ran it on the phone, sent back logs and complained. It took two days, and it fits in a SIS of about 400 KB, game data not included.

It also got me further than I expected before it broke, as you'll see in the section about the toilet.

Where the code comes from

Duke runs on Ken Silverman's Build engine, with 3D Realms' game code on top. Fabien Sanglard's Chocolate Duke3D made the pair portable, and Rockbox took that to small ARM devices. Duke9500 starts from Rockbox's tree at commit 46c888e, throws out SDL and Rockbox, and sits on the native layer already proven by Doom9500 and Quake9500. These are the new parts:

FileLinesWhat it does
display_epoc.c389The Build engine's video, key and timer driver
sys_epoc.c137Start-up, finding the game folder, the log
fx_epoc.c779Sound effects: the mixer
midi_epoc.c1203Music: MIDI sequencer and FM synth
epocsys.cpp952Native layer: window, framebuffer, blit loops, keys, exception handler
epocaudio.cpp345Audio thread feeding CMdaAudioOutputStream
armrt.c18964-bit helpers that egcc.lib lacks

33 of the original files changed too, for about 950 changed lines in a diff. That's much less than Quake needed, and it shows: Build is integer code from top to bottom, and an ARM with no FPU doesn't mind that at all.

320×200 art, doubled across. Duke has never had this wide steroid shoulders.

The compiler goes first

The SDK's compiler is GCC 2.9-psion-98r2, from the last century. The first build didn't get far:

(...\2.9-psion-98r2\cc1.exe 1002) Exception: STATUS_STACK_OVERFLOW
gcc: Internal compiler error: program cc1 got fatal signal 0

The compiler itself was overflowing its stack, recursing on functions full of inlined multiplies. gcc -Q names each function as it starts, so the last name printed is the culprit: hitscan first, then drawmapview. A batch file tried 15 optimisation flags one at a time. Only -fno-inline helped, and that's not something to wish on a renderer.

The way through was to give the compiler simpler code to choke on. The multiply macros now do their shift inside the assembly, so GCC sees one opaque result instead of chains of shifts and ORs. And the automap code, which doesn't need to be fast, calls ordinary functions instead of inlining anything. -O2 then found one more toy, in the code that drives bots:

player.c: In function `computergetinput':
internal error--insn does not satisfy its constraints:
(insn:HI 5662 9270 9273 (set (reg:SI 0 r0)
        (mult:SI (reg:SI 0 r0)
            (reg:SI 0 r0))) 42 {mulsi3}

That's GCC asking for r0 = r0 * r0, which the ARM's mul can't do. The bot's squares went into a small function of their own.

First run: instant panic

Then it built, and it died before drawing anything: Main, QUAKE-EXC, 12. That's the Quake9500 exception handler's name, still on the door. The log's last line had the answer:

Scanning directory '/.rockbox/duke3d/' for a GRP file like 'duke3d*.grp'.

Rockbox's getGameDir() ignored the folder it was told about and always answered with Rockbox's own. On the phone that folder doesn't exist, opendir failed, and readdir(NULL) did the rest. Fixing that exposed the next one: with a real game folder set, script includes went looking for D:/Duke3D/D:/Duke3D/DEFS.CON. The game quit politely with nothing on the screen, which was harder to spot than a panic.

After that fix the game ran: the first rooftop, on a Nokia 9500, with Duke's ship burning overhead.

The alien on the toilet

I played the first level, and I played it again, and each time the phone panicked at the same spot: dropping down the shaft from the roof. A crash you can repeat is a gift, but the log only said Crashed in: blit: pixels, the last marker anyone had set. So the crash handler learned to write out the stack, with the map file from the build alongside it. Among the stack's words sat what looked like the saved registers, and the address they gave pointed here, in ceilscan:

if (sec->visibility != 0)
    globvis = mulscale4(globvis, (uint8_t)(sec->visibility+16));

ldr   r2, =globvis          ; r2 = the address of globvis
smull r8, r2, r1, r3        ; the multiply's high word lands in r2 too
mov   r8, r8, lsr #4
orr   r8, r8, r2, lsl #28
mov   r7, r2                ; r7 "is" &globvis
str   r8, [r7, #0]          ; writes to a small number: page fault

So the bug was ours, born in the fix for the compiler crash. The new multiply macros had an output for the high word that nothing used after the assembly, and GCC 2.9 felt free to give that output a register still holding a live value. The line only runs for sectors with their own visibility setting, and the shaft was the first one I met. That's why it was always the same spot.

A small scanner went through the whole EXE: of 406 of these multiplies, two were really broken. The macros now use ip, declared as clobbered, and the scan of the next build came back clean. After that, I got to play on, and I reached the real goal of any Duke port:

Business phone, they said. High-end, they said.

Black screen on Quit

Menu, Quit, and the phone showed nothing but black. It wasn't a hang. The Plutonium version of the game shows an advert for TEN, the old online service, when you quit, and waits for a key. It fades the picture in by changing the palette. On a PC of 1996 that changes the screen at once. On the 9500's 16-bit screen it only changes a table, and nothing shows until the next blit, so the advert was drawn and waiting in perfect blackness. palto now blits at every step, so the game's other fades should show as well, and the advert is gone from Quit.

Sound: a small mixer

Duke's original audio library is thousands of lines of DOS code about Sound Blasters. Rather than port all of it, fx_epoc.c does its job in under 800 lines on the audio stream that already worked for Doom: 22050 Hz stereo, 16 voices, with the original pan table, pitch table, loop rules and priorities for who gets kicked out.

Sounds are parsed on the game thread when they start, and the audio thread only reads samples. It never calls the game, so finished voices wait for the game thread to collect them. That design is why the first test went like this: I chose the easiest skill, Duke said "Yeaaah, piece of cake", and the game froze. premap.c sits in a loop until that line of speech has ended:

while(Sound[globalskillsound].lock>=200) ;

On DOS a sound card interrupt changed that value behind the game's back. Here nobody did. The loop now does the collecting itself.



Music: an FM chip in software

Doom9500 plays its music as MP3s, but the memory card is only so big. Duke's music is MIDI, and the file that made it sound like anything in 1996, d3dtimbr.tmb, is already in the GRP: the instrument bank for AdLib and Sound Blaster FM chips. So midi_epoc.c is that chain in software:

  • A sequencer on the rules of the audio library's midi.c, with Duke's EMIDI loops and track selection.
  • Voices on the rules of al_midi.c: its pitch tables, volume calculation and voice allocation, up to 18 notes at once. It even keeps the place where the original seems to use the wrong variable, because that's how it sounded.
  • Two operators per voice like the OPL chip, with its waveforms, envelopes, feedback and key scaling, in integers only.

It isn't a chip-exact emulation, but it needs no extra files on the card, and it's the AdLib sound that belongs to this game. I was ready to give up some frames for it. I didn't notice any going.

Keys and the icon

Rockbox's key table was made for a D-pad, so Up also opened doors and Enter fired. The defaults now follow Doom9500. One trap: Duke treats a held Alt as "macro keys". With Chr's strafe toggle on, F-keys would have become chat messages and Chr+R (F4) would have tried to quit. So the Alt key reaches the game disguised as Scroll Lock, and strafe is bound to that.

The icons are made from the game's own art by a Python script: Duke's face from the game's tiles, on the red sky of the cover picture, at 64×50 and 25×20. The sunglasses survive even at the small size.

The launcher is the same recipe as in the earlier posts: a tiny app for the Desk that starts duke9500.exe and exits. It also checks for a duke3d*.grp the same way the game does, and tells you where to copy it if there isn't one.


About the pictures

These aren't photos or captures from the phone. As for the Doom and Quake posts, the real game code was compiled for the PC with a stub in place of the phone layer, and a script walked it around the level with god mode and all weapons. The frames are the 320×200 buffer the phone would get, doubled across and reduced to RGB565 like the 9500's screen. The PC build uses plain C for the multiplies instead of the ARM assembly.

What's missing

  • A frame rate. I haven't measured one, so there's no chart this time. It plays fast, faster than I'd expected, and Build's integer maths must be a big part of that. I have only played part of the first episode so far, too.
  • Multiplayer.
  • Screen shape. 320×200 doubled is wider than 4:3, and a wider field of view is still an open idea.

The controls: arrows or the rocker move and turn, the rocker's middle or Ctrl fires (and it's Enter in menus), Space opens, Shift runs, Alt strafes while held and Chr toggles strafing. A jumps, Z crouches, 1 to 9 and 0 pick weapons, Tab shows the map, Enter uses the selected item and [ ] choose one, Esc opens the menu. Chr with Q W E R T Y U I A S D F gives F1 to F12.

Install the SIS, then copy duke3d.grp from the shareware or the full game to \Duke3D\ on the memory card, or on C: or D:. The game uses the first of those folders that exists. Saves, duke3d.cfg and dukelog.txt land next to it.

Thanks to Ken Silverman for Build, to 3D Realms for the game and for releasing its source, to James R. Dose for the audio library this port learned from, to Fabien Sanglard for Chocolate Duke3D and to the Rockbox people for their port.

Duke's box asked for a 486 at 66 MHz and 16 MB of memory. The 9500 has an ARM at 150 MHz in a coat pocket, plays the AdLib soundtrack in software while it's at it, and has a keyboard for the cheat codes. Come get some.


torstai 5. marraskuuta 2026

Quake9500: id's Quake on a 150 MHz Communicator with no FPU

Last week I asked Claude whether Unreal Tournament could run on the Nokia 9500. The answer was no: a 150 MHz ARM9, no floating-point hardware and no source code. Quake 1, on the other hand, has its source under the GPL, and somebody had already got it to about 10 fps on an iPod Classic.

So now there's Quake9500: the whole game, software rendered, with sound, saves and the full menu, on the Communicator's 640×200 screen. As with NES9500, the code was written by Claude (Anthropic's AI). I built it on my Windows 2000 VM, ran it on the phone, sent back logs and complained. The first build that reached a level ran at about 3 fps. The timedemo now runs at 17.1 fps at full screen height, 4.5 of those from swapping the compiler in version 1.1, and the SIS is well under a megabyte, game data not included.

This post is about how it got there. It gets geekier than the NES one, because Quake fights back.

The patient

Here's the machine, as the game's own log sees it:

HAL display: mode 0, 640x200, 16 bpp
HAL display: memory 0x59912000, first pixel +32, line 1280 bytes
Base directory D:/Quake, hunk 12288 KB, 7740 KB RAM free
Video: 320x200 on a 640x200 screen, aspect 2,00, surface cache 600 KB

The CPU is an ARM925T with an 8 KB data cache, no FPU and no divide instruction. Every float operation is a function call, and so is every integer divide. Quake was written for the Pentium and leans hard on its FPU. The SDK's compiler is GCC 2.9-psion-98r2, so everything is C89.

Where the code comes from

Unlike NES9500, this one is a port with a long family tree. id Software released the source under the GPL, SDLQuake put it on SDL, Dan East's Pocket Quake added fixed-point paths to the renderer for PocketPCs, and Rockbox added ARM fixes for unaligned reads of map and model data. Rockbox gets about 10 fps on a 216 MHz iPod Classic. Quake9500 starts from Rockbox's tree at commit d54b9e6f, throws out SDL and Rockbox, and adds these:

FileLinesWhat it does
sys_epoc.c767Main loop, files, clock, the log, the frame time report
vid_epoc.c197Video driver: buffers, palette, handing frames to the blit
snd_epoc.c132Sound driver: Quake's DMA ring, emptied by the audio thread
softfloat.c1411Our own float helpers and integer divides, with ARM assembly fast paths
armrt.c17164-bit helpers that egcc.lib lacks
epocsys.cpp899Native layer from Doom9500: window, framebuffer, blit loops, keys, exception handler
epocaudio.cpp345Audio thread feeding CMdaAudioOutputStream

On top of that, 46 of the original files changed, for about 2800 changed lines in a diff. The heaviest are r_alias.c and r_draw.c.

Sound was a step backwards on purpose: id's original mixer is back. Rockbox mixed inside the SDL audio callback and needed mutexes everywhere. Now all Quake code runs on the game thread, and the audio thread only copies from the ring and plays silence for anything not mixed yet. The log from a real play session says 3795 chunks mixed, 1 underflows, 0 write errors.

Quake's 2D art is 320×200, doubled across. The menu comes out very wide.

Ways to kill a Communicator

The first days went into crashes, and some of them were spectacular.

  • Stack overflow reboots the phone. Quake likes big local arrays: 128 KB for the pak directory, about 130 KB for the edge and surface lists, 64 KB for the warp buffer. On Symbian an overflow takes the whole phone down. All of them are static now, and EPOCSTACKSIZE is 80 KB.
  • Software floats that trap. Symbian's float helpers raise exceptions where a PC returns NaN or infinity. CalcFov does a 0/0 before the view size is set. A handler installed with RThread().SetExceptionHandler now counts float errors and logs real faults with the QuakeC function and the entity it was working on.
  • A bug from 1996. An alias model frame's bounding box doesn't always hold all its vertices. The crucified zombie in start.bsp has one at y = −2, and D_PolysetDrawFinalVerts then indexes its row table with a negative number. Models with any vertex outside the view now take the clipped path.

3.2 to 1

Quake renders 320×200 and each pixel is doubled across, so a game pixel is twice as wide as it is tall. That's vid.aspect 2.0, and the picture keeps its true proportions. The catch is that the view is 3.2:1. With the usual 90° across, you only see about 35° up and down.



Top: true proportions, the default. Bottom: -aspect 0.83, the vertical field of a PC squeezed into the same 200 rows. You see more of the room, and everyone in it is squashed.

This aspect also broke things. Pocket Quake's 9.23 fixed-point x and y scales overflowed at 2:1 and drew grey bars across the view, so they're 10.22 now. It wasn't the last time this screen shape caused trouble.

The speed hunt

The first number I believed was 7.4 fps in timedemo demo1. Earlier builds reported 12, but they were drawing a broken world, which turns out to be faster.



Every point is a line from the quakelog.txt the phone wrote, like 969 frames 71,5 seconds 13,6 fps. Repeats and test runs are left out.

The first tool was a sampling profiler, and it lied. It put 19% of the time right after the blit, where the wall clock measured 0.1 ms. So it was replaced by timing every part of the frame with the clock: start with -parts and the timedemo writes this at the end. One of these came back after every build. This is the last full one:

Frame times, timedemo: 970 frames, 74.1 ms each
(fl = software float operations, div = integer divides, per frame)
      0.3 ms     55 fl     0 div  input, commands, server
      2.7 ms   1154 fl     0 div  client read
  3D view, of which:
        1.8 ms   1316 fl    19 div  view setup, mark leaves
        9.7 ms   2193 fl   688 div  BSP walk, edges
        4.9 ms   5426 fl    75 div  brush models (doors, lifts)
        5.4 ms   1988 fl     0 div  edge scan
        3.4 ms   2613 fl    23 div  surface cache (lighting)
        4.0 ms   4408 fl     0 div  surfaces: mip level, gradients, rotation
       10.8 ms    759 fl     0 div  texture spans
        2.3 ms    634 fl     0 div  z spans
        2.2 ms   1734 fl    55 div  sky, water, the rest of the surfaces
        2.4 ms   2441 fl     4 div  alias models: bounding box
        0.8 ms    743 fl     0 div  alias models: light point
        4.1 ms   4495 fl   298 div  alias models: setup, transform; sprites
        6.8 ms   1846 fl  1419 div  alias models: triangles (also the view model's)
        0.0 ms      0 fl     0 div  view model: the rest
        2.2 ms   2053 fl   100 div  particles
        1.5 ms      7 fl     0 div  lowres row doubling
        0.0 ms      1 fl     0 div  the rest
      0.3 ms     41 fl     1 div  2D and palette
      7.7 ms      1 fl     0 div  blit
      0.1 ms     76 fl     0 div  sound, rest of the frame

Half-height 3D. The biggest single win is a cheat. With r_lowres 1 the 3D view is drawn at 320×100 into every other row, and each row is then copied into the one below it. A game pixel is now as tall as it is wide, and there's half as much to draw. That took the timedemo from 8.1 to 9.9 fps, and I liked the look enough to make it the default.



Left: r_lowres 1. Right: r_lowres 0. Both zoomed 2×.

Our own soft float. GCC's float helpers on this SDK come from EUSER.DLL and go through Symbian's 96-bit TRealX. softfloat.c replaces __addsf3, __mulsf3 and the rest, with ARMv4 assembly fast paths that give up to C on anything unusual. How do you test hand-written ARM assembly on a PC? tools/armtest.py is a small ARMv4 interpreter that reads the assembly strings straight out of softfloat.c and runs them against a Python mirror, which is itself checked against exact fractions. Zero failures. The result on the phone: 9.9 to 10.2 fps. A small gain, so floats weren't the big lever.

Our own divides. Same treatment for __udivsi3: binary search for the top bit, then a computed jump into a chain of three instructions per bit. No speed change at all, libgcc's was already that good. But counting the calls gave us a new number: 21,661 integer divides per frame.

The divide that should have been a multiply. Most of those came from Pocket Quake's fixed-point edge code, which "multiplies" by dividing by a reciprocal:

transformed_fxp[0] = local_fxp[0]/vright_fxp[0]+local_fxp[1]/vright_fxp[1]+local_fxp[2]/vright_fxp[2];

That's 11 divides per vertex and 6 per clip-plane test, on a CPU with no divide instruction. It does have smull, which gives the high word of a 32×32 product and costs next to nothing:

transformed_fxp[0] = MulHi(local_fxp[0],vright_mul[0])+MulHi(local_fxp[1],vright_mul[1])+MulHi(local_fxp[2],vright_mul[2]);

BSP walk and edges went from 18.7 to 10.9 ms and from 12,614 divides per frame to 600. It's also more accurate: the worst projection error dropped from 0.8 to 0.03 pixels.

Spans without divides. D_DrawSpans8 did two divides per 16 pixels for perspective correction. Now it normalises 1/z, looks up a 256-entry reciprocal table and refines it with one Newton step:

r = span_rtab[(d >> 22) & 255];
e = UMulHi (d, r);                     // d*r/2^60 = 1 - error, as 2^28
r = UMulHi (r, 0x20000000u - e) << 4;  // r * (2 - d*r)

A Python check put the error at 0.004 of a texel, and the timedemo went from 11.0 to 11.4 fps.

Models and particles. Alias models got a fixed-point transform (a 16.16 matrix, smull, one 231/z per vertex), a fixed-point bounding box test with 64-bit edge compares, a matrix cached per entity per frame and a light point cache. AngleVectors reads a 16384-step sine table, because the SDK's double-precision sin and cos are slow. Particles went fixed-point too, from 4.7 to 2.3 ms. Together that's worth close to 2 fps.

A compiler bug. After the fixed-point bounding box went in, more models took the slow clipped path, and we couldn't see why. r_bboxfx 2 ran both tests and logged every disagreement. The fixed-point boxes were fine, and the float ones were nonsense: the grenade launcher pickup had a box like a 10-unit stick, and ogres had boxes pointing down. The float test had been wrongly rejecting models that should have been drawn. The culprit is this perfectly ordinary line from id's code, which GCC 2.9 gets wrong with soft floats:

basepts[0][0] = basepts[1][0] = basepts[2][0] = basepts[3][0] =
        (float)pframedesc->bboxmin.v[0];

Written out one value at a time, every disagreement disappeared from the log. The same pattern got split up in sv_move.c, sv_user.c and world.c, just in case.

The log file. My bug report said "Huuuuuuge lag in the beginning when picking up stuff". "You got the shells" goes to the console, and the console went to quakelog.txt with an fopen, an append and an fclose on the memory card, for every line. The log now goes to an 8 KB RAM buffer once the game is running. "Client read" went from 7.8 to 2.6 ms, the jitter vanished, and together with the bounding box fix that was the jump from 12.5 to 13.7 fps. It's the best return on effort in the whole project.

The blit. Each 8-bit pixel has to become two 16-bit ones. A second palette table holds every colour already doubled in a 32-bit word, so the loop is an and, a load with a scaled index and a four-word store:

"ldr r7, [%0], #4\n\t"
"and r4, r7, #0xff\n\t"
"ldr r4, [%3, r4, lsl #2]\n\t"
"and r5, r7, #0xff00\n\t"
"ldr r5, [%3, r5, lsr #6]\n\t"
"and r6, r7, #0xff0000\n\t"
"ldr r6, [%3, r6, lsr #14]\n\t"
"mov r7, r7, lsr #24\n\t"
"ldr r7, [%3, r7, lsl #2]\n\t"
"stmia %1!, {r4, r5, r6, r7}\n\t"

That took it from 9.3 to 7.7 ms. Two cleverer ideas gave nothing: looking up each doubled row only once, and skipping rows that hadn't changed. The limit is writing 256 KB per frame to screen memory, at about 34 MB/s.

Coarser mips. d_mipscale 2 picks the next smaller texture one step earlier. The surface cache went from 4.3 to 2.8 ms, and the timedemo from 14.7 to 15.0 fps.

Top: d_mipscale 1. Bottom: d_mipscale 2, the default. This is the worst wall I could find, and on a monitor the bigger blocks are easy to see. My verdict on the phone was "Not blurry at all!"

Knowing when to stop. A per-plane cache of the 1/z gradients cut the floats in the BSP walk from 3256 to 1945 per frame, and the time didn't move at all. One software float operation costs about 30–40 cycles, so removing more of them is close to pointless. What's left is integer work and memory: texture spans take around 30 cycles per pixel, most likely cache misses on texels in that 8 KB data cache.

Killing the status bar

The status bar takes 48 of the 200 rows, which hurts on this screen. It's gone, as in Doom9500. The 3D view always gets the whole height, and H draws the bar over the view when you want to know how much health you have left.

Without the bar and with it.

The key is bound with a new bindifunbound h togglestats after quake.rc, because default.cfg does an unbindall, and a plain bind would overrule your config. And since nearly any key during a demo opens the menu, keys.c now makes an exception for that one. Drawing 200 rows instead of 152 costs speed: the timedemo fell from 15.0 to 13.2 fps. Removing the dirty-row blit, which had nothing left to skip, brought back 13.6. That dip in the chart is one I chose on purpose.

The map called ",000000"

Saving took a very long time, and then this happened on loading:

Loading game from D:/Quake/id1/s0.sav...
FindFile: can't find maps/,000000.bsp
Couldn't spawn server maps/,000000.bsp
Couldn't load map
load failed.

The phone is set to Finnish, and the C library's printf follows the locale: "%f" writes something like 4096,000000. Its fscanf doesn't, and reads a %f only up to the comma. So the first number stopped at the comma, every later %f failed on it, and when the code then asked for the map name, it got the leftover ,000000.

There's now a small Q_ftoa that always writes a point and doesn't go through the library's slow float formatting. The loader reads numbers as words and parses them with Q_atof, which accepts both, so older saves should load too.

The slowness was id's code being careful: a fflush after every entity, several hundred times per save, on top of the library's 1 KB buffer. A 256 KB setvbuf and no flushes means the whole file goes to the card in one write.

While I was complaining, the volume got fixed too. The slider was linear, so its lowest step was only 20 dB down. It's now 5 dB per step, which makes 0.1 about −45 dB, and full volume is as loud as before.

The gun below the screen

Then I actually played it, and noticed that the axe was almost off the screen and the shotgun wasn't there at all. That's the screen shape again. Quake's weapons sit below the line of sight, and a PC's default view (320×152 at pixel aspect 0.83) sees about 59° up and down. Ours sees 35°, so the gun is simply below the bottom edge. A timedemo doesn't complain about that.

The fix gives the gun its own projection. All the alias code reads the scale and the centre from globals at draw time, so R_DrawViewModel swaps them while it draws the gun. fit makes the gun look as it does on the PC's view, fitted to our height. That looked small on such a wide screen, so r_gunscale (default 1.4) then grows it upwards from the bottom edge:

fit = verticalFieldOfView * (1.0f / GUN_PC_VFOV);    // 0.625 / 1.14
if (size > 0 && fit < 1)
{
    bottom = (r_refdef.vrect.y + r_refdef.vrect.height - 0.5f) * r_aliasuvscale;
    aliasxscale = xs * fit * size;
    aliasyscale = ys * fit * size;
    aliasycenter = yc + (bottom - yc) * (1 - size);
}

From the top: the axe in true perspective, the axe now, the shotgun in true perspective (yes, it's there, somewhere below the screen), the shotgun now.

Shadows on the barrels

Now that the guns were finally visible, the double-barrelled shotgun had dark patches all over the tops of its barrels. They were there all along, just below the screen.

Left: the bug. Right: the fix. Zoomed 3×.

To find it, Claude compiled the game's own d_polyse.c for the PC twice, once with Pocket Quake's integer gradients and once with id's original float version, and fed both the shotgun's triangles. The integer version showed the patches, and logging both versions side by side pointed straight at the depth steps:

tri 4 zx: int -13639920, exact 31570262 (dxdenom -95)
tri 5 zx: int 5803844, exact -19312923 (dxdenom -171)

Wrong by miles, and with the wrong sign. Each step is a difference of two products divided by twice the triangle's area, in 32-bit ints:

r_zistepx = (t1 * p01_minus_p21 - t0 * p11_minus_p21) / d_xdenom;

Here t0 and t1 are differences in 1/z, stored as 231/z. The gun's z is scaled 3× to keep it in front of the world, and it comes as close as 5 units, so those differences approach 230. Multiply that by a few dozen pixels and it no longer fits in 32 bits. The depth test then lets the dark wood behind the barrels win. A monster right in your face can hit the same overflow.

The fix drops just enough low bits first, so that the products fit. Screen distances are below 2048 here, so 20 bits always do. Small differences are calculated exactly as before:

m = (t0 < 0 ? -t0 : t0) | (t1 < 0 ? -t1 : t1);
if (m < (1 << 20))
{
    *stepx = (t1 * p01_minus_p21 - t0 * p11_minus_p21) / d_xdenom;
    *stepy = (t1 * p00_minus_p20 - t0 * p10_minus_p20) / ydenom;
    return;
}
sh = 1;
while ((m >> sh) >= (1 << 20))
    sh++;
t0 >>= sh;
t1 >>= sh;
*stepx = (t1 * p01_minus_p21 - t0 * p11_minus_p21) / d_xdenom * (1 << sh);
*stepy = (t1 * p00_minus_p20 - t0 * p10_minus_p20) / ydenom * (1 << sh);

On the default size the shotgun went from 263 pixels different from id's float code to zero. The test lives on in tools/polytest.

A debt from the NES9500 post

In the NES9500 post I said that Quake9500 still polled window server events the old way. It did, right up until writing this post. The loop looked at iEventStatus and never took the completed request's signal off the thread's semaphore, so every event left one stale signal behind, and every later wait had to spin through all of them. That may well be why the game got sluggish after a while. The fix is one line at the top of the poll loop:

while (w.iEventStatus != KRequestPending)
    {
    User::WaitForRequest(w.iEventStatus);
    TWsEvent event;
    w.iWs.GetEvent(event);
    w.iEventStatus = KRequestPending;
    w.iWs.EventReady(&w.iEventStatus);

How these screenshots were made

None of the game pictures in this post were taken on the phone. They come from the phone's code, though: the same C files compiled for a PC, with a stand-in for epocsys.cpp and epocaudio.cpp that has no window and writes frames to disk. Only the speed numbers and the logs come from the phone.

Quake isn't 64-bit clean, as it casts pointers to int and long all over the place. On 64-bit Windows both are 32 bits, so normally that would crash at once. The trick is to keep the whole process below 2 GB, where those casts lose nothing: link the image at 4 MB, turn off ASLR, and clear IMAGE_FILE_LARGE_ADDRESS_AWARE in the PE header with a few lines of Python, because this ld has no option for it. Add -fwrapv so ints wrap as they do on the ARM, and a virtual clock so every run gives the same frames. A small script says what happens at which frame, such as 120 cmd impulse 3 or 320 shot b_new. It ran on the first try.

The obvious question is whether a PC build really draws what the phone draws, since it uses the x86's floats and not softfloat.c. So we ran the same timedemo on it. Same 969 frames, and this line from the report is identical to the phone's, character for character:

Alias models per frame x10: 90 rejected, 8 clipped, 30 unclipped; 6 by the float test; 0 unclipped had to be clipped (r_bboxfx 1)

Timings are meaningless on the virtual clock, of course.

The console, on Chr+Z. timeframes starts and stops a speed report for real play.

The launcher and the icon

The same recipe as in the NES9500 post: a tiny app for the Desk that starts quake9500.exe and exits. This one also checks that pak0.pak exists and tells you where to copy it if it doesn't, because the game itself can only stop with an error.

The icons come from Quake's own console background. The Q is dark brown on brown there, so a Python script masks it out by brightness and redraws it in rust on the darkened texture, at 64×50 and 25×20.

Version 1.1: a compiler from this decade

Everything above was built by the SDK's GCC 2.9 from 1998. For my Resident Evil experiment, Claude had found a way to run code from a modern GCC on the 9500: build it on the PC as one flat block of ARM code, and have a small program built by the SDK load it. In 1.1 Quake works that way. quake9500.exe is still built by the SDK, but it only holds the native layer (window, blit, keys, the sound thread) and a loader. All of Quake's C is built by GCC 14 into quake9500.rbl, with newlib as its C library. The exe reads it into a chunk of memory, adds the load address to every pointer in its list, flushes the instruction cache, and calls it with a table of functions for everything the phone does, files included.

The two compilers agree on less than you'd hope. Only ints and pointers can cross between them, because GCC 2.9 stores a double with its two halves the other way round. GCC 14 expects the stack aligned to 8 bytes at every call and GCC 2.9 doesn't care, so calls into the game go through a few lines of assembly that align it. GCC 14 also makes enums as small as they fit, unless told not to, and that breaks the structs Quake reads straight out of its model files. And the 9500 kills a program that reads a word from an address that isn't a multiple of four. So before it went near the phone, Claude wrote an ARM interpreter in C that runs the whole .rbl through the timedemo and stops on anything that would kill it there. It found nothing, and neither did the phone.

The same timedemo, one run each:

Quake9500 1.0, GCC 2.9    969 frames  76,6 seconds  12,6 fps
Quake9500 1.1, GCC 14     969 frames  56.7 seconds  17.1 fps

That's 1.35 times as fast without touching the game's code. Every part of the report took 14 to 42% less time, while the blit, which is GCC 2.9 code in both builds, stayed the same, so it wasn't the phone having a good day. Claude's guess before the run was 10 to 20%. A profile-guided build gained nothing on top. (Yes, 1.0 is at 12.6 and not the 13.6 above: the gun fixes came after that number, and the model triangles part of the report doubled, from 6.8 to 12.9 ms.)

There's a bonus for the next round of speed work, too: a new build of the game no longer needs the VM. Any .rbl copied to the Quake folder and named with -blob in quakeargs.txt gets loaded instead, with a log of its own.

What's missing

  • Music. Quake's CD tracks could be MP3s through Doom9500's libmad.
  • Multiplayer and the mission packs.
  • ARM assembly for the span drawers. Rockbox's notes call those the big ones, and they're still among the biggest parts left in the report.
  • A proper number for real play. The timedemo never runs the game server, so there's no monster AI, QuakeC or physics in the 17.1 fps. It plays well enough for me.

The controls: arrows or the rocker move and turn, the rocker's middle, Enter or Ctrl fires, Space jumps, Shift runs, Alt strafes while held and Chr toggles strafing, Backspace or / changes weapon, H shows the status bar, Esc opens the menu and Chr+Z the console. Chr with Q W E R T Y U I A S D F gives F1 to F12, with F6 for quicksave and F9 for quickload.

Install the SIS, then copy id1\pak0.pak from the shareware or the full game (the full game also needs pak1.pak) to \Quake\id1\ on the memory card or to C:\Quake\id1\. Saves and config.cfg land next to it, and quakelog.txt goes in the Quake folder. Quake9500 is GPL like the code it's built on, so the source goes out with the SIS.

Thanks to id Software for Quake and for releasing its source, to SDLQuake, to Dan East for Pocket Quake and to the Rockbox people for their port.

Quake's box asked for a 75 MHz Pentium. The 9500 has twice the clock, no FPU, no divide instruction and a screen shaped like a letterbox. Seventeen frames per second, and it fits in a coat pocket.

maanantai 2. marraskuuta 2026

Doom9500: PlayStation Doom's sounds and music on a Nokia 9500 Communicator

The best way to play Doom is the PlayStation version. I will not be taking objections. Those long, dark, reverberating sound effects and Aubrey Hodges' horrible, wonderful ambient music turn a cheerful 1993 shooter into something that makes you check behind you.

The Nokia 9500 Communicator already had a Doom. C2Doom is closed source and never ran that nicely for me, so nothing could be done about it. So one Sunday in October I asked Claude a question: do you want to make the fastest Doom on the 9500, with custom sound and music WAD support, so that I can play it with the PSX audio? "In or out?"

Claude was in. The result is Doom9500: PrBoom 2.2.6 on the 640×200 screen, drawing straight into the framebuffer, with a software mixer and MP3 music decoded on the phone while the game runs. As with NES9500 and Quake9500, the code was written by Claude (Anthropic's AI). I built it on my Windows 2000 VM, ran it on the phone and complained. The very first build that linked played with working sound effects, and by the end of that same day it was playing the whole PlayStation soundtrack. My exact words in the bug report were "Doesn't seem to have any issues!!! No sluggishness whatsoever."

This post is about how that was possible in a day. Spoiler: it wasn't really a day. It was a day plus a month of earlier suffering on a different phone.

The month before

Doom9500 is a fork of Doomunicator, my PrBoom port for the Nokia 9210. That one took a month of real-hardware debugging, and most of it carried straight over:

  • The renderer draws a native 640×200 picture, not a doubled 320×200.
  • ARM assembly for the wall column and floor span loops, and for every FixedMul, FixedDiv and SlopeDiv in the game. They give bit-for-bit the same results as the C versions, or the title demos would go out of sync, which makes the demos a free test suite.
  • Auto detail drops to half the columns when the average frame goes over 120 ms and comes back under 55 ms. Z cycles High, Low and Auto.
  • A full-screen 3D view with a heads-up overlay, because a status bar on a 200-row screen is a luxury.

High detail on top, low detail below, both zoomed 2×. Low renders half the columns and doubles them.

The 9210 needed all of that. In the first log from a real phone, a level ran at about 9 fps with a 1.7 MB memory zone. Its SDK compiled everything at -O0, so the hot loops had to be written by hand. Near the end, the 9210 got about 8–9 fps in a big hall on high detail and 15 or more on low.

When the 9210 started shutting itself off at random and missed one of my alarms, I moved to the 9500. Many 9210 apps run on it untouched. Doomunicator isn't one of them: it starts and draws a few pixels at the top of the screen.

Throwing out SDL

The obvious fix was to find out why the old driver was confused and patch it. We didn't. Doomunicator got its screen, keys, timer and sound from a static SDL library, built for a different SDK with 9210 assumptions in it. For the fastest possible Doom, the right number of layers between the renderer and the screen is zero. So SDL went, and two small files came in:

FileLinesWhat it does
epocsys.cpp626Window group, framebuffer, blit loops, keys, clock
epocaudio.cpp343Audio thread feeding CMdaAudioOutputStream
mp3music.c225Music: libmad, resampling, mixing
i_sound.c571PrBoom's mixer, rewritten for a real audio thread
i_video.c255PrBoom's video interface, now a thin wrapper

If you've read the NES9500 and Quake9500 posts, you've met these files. This is where they started.

The new layer doesn't assume anything about the screen. It asks the HAL for the memory address, colour depth, offset to the first pixel and line pitch. The 9500 answers like this:

HAL display: mode 0, 640x200, 16 bpp
HAL display: memory 0x59912000, first pixel +32, line 1280 bytes

The renderer still draws 8-bit pixels, so each one goes through a 256-entry table on its way to the screen. That table is the only part that knows about colour formats:

if (gBpp == 16)
    gPalette[i] = TUint16(((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3));
else
    gPalette[i] = TUint16(((r & 0xf0) << 4) | (g & 0xf0) | (b >> 4));

Doomunicator's ARM blit loops came along unchanged, because they copy 16-bit table entries without ever looking at them. They served 12-bit colour on the 9210 and now serve RGB565 on the 9500.

There's also a free speed-up in the toolchain: the S80 v2.0 SDK builds release code at -O, where the 9210's SDK used -O0. All of the game logic and every line of C that nobody ever bothered to hand-optimise now goes through the optimiser, without anyone touching it.

The menu is PrBoom's, drawn on the 640×200 screen. The rocker's middle press is Enter here, more on that below.

Three builds to first light

For the record, here's every error between the fork and the phone:

  1. 'gOpen' undeclared. Claude moved the window objects into a struct, because nobody's sure whether an EKA1 EXE runs global constructors, and deleted one line too many.
  2. conflicting types for 'TInt const w'. The blit had a window reference called w and a row width called w. Same refactoring, same Sunday.
  3. undefined reference to __crt0. ECRT0.LIB needs a function from ESTLIB.LIB, and the linker had already gone past that library. It's STATICLIBRARY now, which links first. Doomunicator never hit this because SDL brought its own startup code.

The fourth attempt linked, and I put it on the phone. My report back was "Claude. The game works AMAZINGLY. Sounds work awesomely!"

Doom's 320×200 title art across the whole Communicator screen. It has never looked this wide.

From one voice to a real mixer

Sound on the 9210 was a war. Its ROM has no streaming audio DLL at all. All you get is a player that plays a file, and only one at a time. So each Doom effect became a tiny WAV in memory with its own player object. Creating one takes 0.3 to 1.3 seconds, which gave me sound with a one to two second lag at first. Players were then cached per effect and preloaded. The chaingun needed its own hack: restarting a player costs about 80 ms, the gun fires every 114 ms, and that left about 25 ms of each shot audible. So we mixed eight shots into a seamless pre-rendered rattle and looped it. Music was tried too, and the 9210's media server turned out to have no MIDI codec, so that version stayed silent.

The 9500 has CMdaAudioOutputStream, so all of that could be deleted. PrBoom has always had a software mixer, and it only needs somewhere to send its output: 22050 Hz stereo, 8 channels by default, every effect at once. No lag per effect, and no chaingun hack.

The hard lessons had already been paid for, by Rayman. Getting that game's sound working on the 9500 taught us two things that went straight into epocaudio.cpp:

  • The stream needs an active scheduler and a cleanup stack, and Doom's main loop has neither. So it runs in a thread of its own.
  • MaoscBufferCopied lies. On the 9500 it fires when the framework has copied a buffer, not when it has been heard. Feed the stream every time it fires, and you pile up latency. So the audio thread works out what has been played from the clock, and only mixes more while it's less than three mix calls ahead:
const TInt64 us = now.MicroSecondsFrom(iClockStart).Int64();
const TInt64 heard = us * TInt64(iShared.iRate) / TInt64(1000000);
const TInt played = iClockFrames + I64INT(heard);
return played < iQueued ? played : iQueued;

Each mix call is 512 samples, about 23 ms, so that's roughly 70 ms in the queue. A CPeriodic asking for 20 ms really ticks about every 31 ms on this phone, so one tick may mix more than one chunk.

Moving to a real audio thread also exposed a bug that had been in the design for years. PrBoom's mixer releases a sound's WAD lump when the sound ends, and it did that from inside the mix callback. That means calling into Doom's zone allocator from the audio thread, while the game thread might be in the middle of allocating from it. That's the kind of bug that crashes once a week. Now a finished channel only clears its data pointer, and the game thread releases the lump the next time it looks at that channel.

psxsound.wad: no code required

I had prepared myself for a format battle, so here's the entire engineering effort for the PSX sound effects:

-file psxsound.wad

All 107 effects are standard Doom sound lumps at 11025 Hz with the standard DS* names. PrBoom loads the WAD and the new sounds replace the old ones. The mixer steps through each sample at its own rate, so the pack would be fine at any other rate too.

The shotgun, drawn from the actual WADs at the same time scale. Grey: PC Doom, 0.86 seconds. Red: PSX, 2.8 seconds, nearly all of it reverb. This is what you're paying for, and it's why you want more than one voice: on the 9210 each new sound would have cut that tail off.

psxmusic.wad: 192 minutes of MP3

The music is another matter. Opening the WAD gave me this:

PSXMUSIC.WAD  PWAD  67 lumps
  D_E1M1    1963760  ff f3 48 64 ...
  D_E1M2    1198546  ff f3 48 64 ...
  ...
  D_RUNNIN  1963760  ff f3 48 64 ...

ff f3 is an MPEG audio frame. These are MP3s stored in Doom's music lumps: mostly MPEG-2 at 16 kHz, stereo, 32 kbps, plus a few at 22 and 44.1 kHz. The longest, E1M4, runs for over nine minutes. There are 36 different tracks and 192 minutes in total, and all of the Doom 2 names point at the same data as the Doom 1 names, which is why the directory adds up to 94 MB and the file is only 49 MB.

That left a real decision, and Claude left it to me:

OptionOn the cardThe catch
Convert on the PC to IMA ADPCM, stereo184 MBAlmost free to decode, but needs a converter step and a big card
Same, mono92 MBLoses the stereo of the ambient tracks
Decode the MP3 on the phone49 MBCosts CPU in a project whose whole point is speed

Claude recommended ADPCM, since speed was the goal. I picked MP3 anyway, because it means that a music WAD just works as it is, with no conversion step. A 150 MHz ARM9 decoding MP3 on top of Doom sounded greedy. It turned out not to be.

The decoder is libmad 0.15.1b by Underbit: fixed-point, which you need on a CPU with no FPU, and GPL like PrBoom. Its FPM_ARM mode uses the ARM's smull and smlal, which the 9500's ARM925T has. Using it took three small adjustments:

  • mad.h as shipped is generated for MSVC and hard-codes FPM_INTEL. It now picks by platform, and libmad gets its own config.h.
  • libmad's version.c would compile to the same VERSION.o as PrBoom's version.c. Nothing uses it, so it's gone.
  • The Layer III decoder mallocs two buffers on first use, from whichever thread is calling. Our audio thread has its own small heap, separate from the game's, and I'd rather not have a decoder allocating there. If those pointers are already set, it never allocates, so mp3music.c owns the buffers and sets them itself. This is why it never calls mad_stream_finish.

Doom's own music code loads the whole lump into the zone and keeps it locked while it plays, so the MP3 is simply in memory, up to 2.2 MB per track. The zone went from the 9210's 2.5 MB to 8 MB, which fits it easily. The cost is a little extra time when a level loads. In exchange, the audio thread never has to touch a file.

Decoding happens inside the mix call, a frame at a time: music first, then the effects added on top. PSX tracks come out at 16 kHz and the mixer runs at 22050 Hz, so there's a linear resampler. It keeps the last sample of each decoded frame at index 0 of the buffer, so it can interpolate straight across frame boundaries:

while (idx >= mp3->n) {
  if (mp3->n) {
    /* keep the last sample for interpolating into the next frame */
    buf[0] = buf[2 * mp3->n];
    buf[1] = buf[2 * mp3->n + 1];
    mp3->pos -= mp3->n << 16;
  }
  if (!DecodeFrame(outrate))
    return;
  idx = mp3->pos >> 16;
}

Full music volume is 3/4 of full scale, which leaves room for the effects. The audio thread's stack went up to 32 KB, since the Layer III decoder keeps about 5 KB of arrays on the stack.

None of this could be tested on the PC at the time, as I had no compiler there yet. So it went onto the phone untested. It worked on the first try, and that's when I reported the lack of sluggishness. Doom's original MUS music isn't played at all, because there's no MIDI synth. To me that isn't a loss. You're here for the PlayStation soundtrack.

The barrels are too fat

The 9500's screen has the same shape as the 9210's, and I'd fought this one already. PrBoom's vertical projection assumes a 4:3 monitor. At 640×200 on a screen with square pixels, that makes the 3D view twice as wide as it should be. Barrels are fat and imps are wrestlers. It's also how every pixel-doubled port on this screen looks, and after a while, you stop noticing.

Top: wide, the default. Bottom: true proportions, on J. Look at the fireball. It's the same 90° across, but you see far less above and below.

The fix is a single line that makes the vertical scale equal the horizontal one. Wide stayed the default, since true proportions on this screen only show about 35° up and down. The same mismatch also explained a stranger bug, where a dead marine vanished as you walked up to it. PrBoom's sprite height clip used the horizontal scale for a vertical test, which never matters on a 4:3 monitor. It uses the vertical scale now.

The rocker, and Chr

Then I actually played it, and asked for two things. The 9500's rocker did nothing, and I wanted Chr to toggle strafing.

The rocker was already measured, for Rayman. It sends the normal arrow key codes, which is why other apps work with it, but its own scan codes: up 0xac, down 0xad, left 0xaa, right 0xab, middle 0xae. Doom needs press and release events, and those come with scan codes. Five lines in a switch, and it worked.

The middle press fired the gun, which was right until I opened the menu and it fired the gun there too. epocsys.cpp can't know whether a menu is open, so now it reports a special key and i_video.c decides at the moment of the press. What the press sent is remembered, so that the release always matches it, even if a menu opens while you hold the button:

if (key == EPOC_KEY_SELECT) {
  /* Enter while a menu is up, fire otherwise; the release matches the press */
  if (down)
    select_sent = menuactive ? KEYD_ENTER : key_fire;
  key = select_sent;
}

Chr toggling strafe is one of those small things that changes how the game feels on this keyboard, because holding Alt with one finger while steering is no fun. Each tap sends Doom a press or a release of its strafe key. The catch is that Chr + Q, W, E and so on are how you get F1 to F12 on this keyboard, for saving and loading. So if another key goes down while Chr is held, the release of Chr flips strafe back, as if nothing had happened.

Finally, logging got turned off. Every line went through fopen, an append and fclose, all during startup. It's behind a DOOM_LOG switch now, except that a fatal error still writes its message to C:\doomlog.txt. Yes, Quake9500 went on to learn this exact lesson the hard way. The event polling bug from the NES9500 post was in here too, and it got the same one-line fix.

The launcher, and some surgery on the logo

A plain .exe can't sit on the Desk, so the 9210 launcher got ported: a tiny app that starts doom9500.exe and exits to give back its RAM. Its Args button edits the game's options, so -file psxsound.wad psxmusic.wad is typed once, on the phone, and saved to doomargs.txt. The port also fixed a bug from the 9210 version: _LIT(KArgsFileC, "C:\doomargs.txt"). Spot the single backslash.



The icon, its mask and the result on a blue Desk, zoomed 3×. The artwork came as two versions, one on white and one on black, and neither was a mask. Any pixel that differs between them must be background, so a short Python script gets the mask exactly right.

The launcher's background said "CommDoom", the 9210 port's name. I asked for "Doom9500" in the same letters, which raises the question of where you get digits in that font. It's Doom's own red menu lettering at 2×, and the menu never needed digits. But "Doom" was already there, as the right half of "CommDoom", and the intermission screen's numbers (WINUM0–9 in the WAD) have the same red shading and dark outline. So make_title.py cuts "Doom" out of the old title and adds 9, 5, 0 and 0 from the game's own graphics at 2×, on the same baseline.



How these screenshots were made

Same trick as in the Quake9500 post. None of the game pictures here were taken on the phone. They come from the phone's code: the same C files compiled for a PC with a stand-in for epocsys.cpp and epocaudio.cpp, with a script that presses keys at given frames and writes frames to disk on a virtual clock.

One new wrinkle: GCC 2.9 accepts static int basetic; after extern int basetic;, and a modern GCC refuses to compile it. Removing static isn't quite right either, because another file has its own basetic. So the build script compiles a copy of the source, and for each error it removes that static and adds #define basetic d_main_basetic at the top of the file, which keeps it private to that file. It ran first try after that, and played id's demos correctly.

What's missing

  • A number. I haven't run a timedemo on the 9500 yet, because it has simply felt fast.
  • More speed. libmad's ARM assembly for its slowest step (imdct_l_arm.S) isn't built yet. I also haven't tried o2.bat, the script that rebuilds the hot files at -O2, on this SDK.
  • The BOOM translucency map is still off from the 9210 days, when 64 KB and a slow startup mattered. On the 9500 it probably shouldn't be.

The controls: arrows or the rocker move and turn, Ctrl or the rocker's middle fires, Space opens doors, Shift runs, Alt strafes while held and Chr toggles strafing. Z cycles the detail, J toggles the proportions, and Chr with Q W E R T Y U I A S D F gives F1 to F12.

Install the SIS, and it puts Doom9500 on the Desk and prboom.wad in \Doom\. Copy your doom.wad, doom2.wad or the shareware doom1.wad to D:\Doom\ on the memory card, or to C:\Doom\. For the PlayStation experience, add psxsound.wad and psxmusic.wad and press Args in the launcher. I can't give those WADs to you, but they aren't hard to find. Any other music pack with MP3s in its D_* lumps should work the same way. Doom9500 is GPL like the code it's built on, so the source goes out with the SIS.

Thanks to id Software for Doom and for releasing its source, to the PrBoom team, to Hannu Viitala and Markus Mertama for CDoom, which showed it could be done on a Communicator back in 2001, to Underbit for libmad, and to Aubrey Hodges for the music that started this whole thing.

E1M1 was never meant to sound like this. Turn the lights off, hold the 9500 close, and listen to that shotgun ring out for three whole seconds on a phone from 2004.

sunnuntai 1. marraskuuta 2026

NES9500: a NES emulator for the Nokia 9500 Communicator, from scratch

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.

FileLinesWhat it does
cpu.c4126502 interpreter: one big switch over all 256 opcodes, unofficial ones included
ppu.c637Scanline renderer using loopy's scroll model, writing straight into the framebuffer, already scaled
apu.c539Two pulses, triangle, noise and DMC, non-linear mixing, 22050 Hz mono
nes.c569Memory map, iNES loader, mappers, frame loop, save states
nesmain.c1253ROM list, menus, settings, key config, frame pacing
epocsys.cpp971Native Symbian layer: framebuffer, keys, menus, files
epocaudio.cpp345Audio 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.

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!!.