tiistai 10. marraskuuta 2026

RE9500: Resident Evil on a Nokia 9500 Communicator, movies and all

After Doom9500, Quake9500 and Duke9500, there was one 90s game left that I really wanted on the Communicator, and it isn't a shooter. It's the one with the zombie that slowly turns its head.

RE9500 is Resident Evil, the 1997 PC version, on the Nokia 9500. It has the cutscenes, the voices, the sound effects, the music and all 27 movies, plus a few things the original never had: save anywhere, separate volumes for music and sound, and a picture around the game on the wide screen. 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. From the first test to the zombie on the Desk took five days.

This one is different from the others under the hood. The SDK's compiler couldn't build it at all, so most of the game was built by a compiler from this decade and loaded by a small program from the last one.

Where the code comes from

Capcom never released the source. What exists is a decompilation: ecruells/resident-evil-pc-decomp (GPLv3), the PC game rebuilt as about 154,000 lines of modern C++ that builds for Windows and Linux. RE9500 starts from its commit 0173055. The game's data isn't in it and never will be: you need your own copy of the PC game.

C++17 means templates, std::vector and lambdas. The Series 80 SDK's GCC 2.9-psion-98r2 is from the last century and can't take any of that. Quake9500 1.1 had already tried the way around it:

  • The game is built by Arm's GCC 14 into re9500.rbl: 1.3 MB of code and data that becomes 9.6 MB in memory, plus a list of the 14,500 places that hold an address.
  • The stub, re9500.exe, is built by the SDK as usual. It loads the blob into memory, fixes those addresses for wherever it landed, and hands it a table of the phone's services: files, the screen, keys, sound and the clock. The blob never calls Symbian itself.

A side effect I like: a new blob is just a file. Most of the builds in this post went straight to the memory card without the VM.

Before the game, a test called BlobTest checked the idea on the phone: constructors, std::vector and std::string, the C library, soft floats, all fine. It also confirmed something nasty. A misaligned memory read on the 9500 doesn't return rotated garbage the way a plain ARM9 does. It kills the thread with exception 11, and the decompilation reads plenty of game data that way.

An ARM emulator first

So the first big piece was armemu, an emulator for ARMv4 code that Claude wrote in about 1,400 lines of C. It runs the same re9500.rbl as the phone, plays the stub's part, and stops dead on a misaligned access just like the 9500. It writes frames as PNGs, records the sound to a WAV file and profiles the code. It counts time by instructions, and that estimate turned out to be close to the phone: the hall's cutscene took 310 ms per frame in armemu and 271–329 on the 9500.

The game ran in armemu from the title screen into the mansion before it ever touched the phone. Every picture in this post comes from it, too, but I'll get back to that.

First run: two crashes, one of them mine

The first run showed a black screen and then EXCEPTION 12, a page fault. That one was me: I had copied the data to \RE9500 instead of \RE9500\USA. armemu with an empty data folder gave the same log line for line, and the game now checks for its data at start and says where it looked.

With the data in the right place it ran the first time: stub, blob, framebuffer and keys. My report said "extremely slow", but it ran. Then Chris's intro died with exception 11 after the fifth camera cut in the hall. The room scripts are byte streams, and the decompilation reads them through 16-bit pointers at odd addresses. A script, port2.py, now rewrites every pointer to a 16- or 32-bit type in the game's code to one that reads byte by byte. It touches about a hundred files and costs 0.1% of the speed. No misaligned read has shown up since.

From 300 milliseconds to 60

The game works out one tick every 33 ms, and the PC version draws every one. At 300 ms a frame, it ran at a tenth of its speed. armemu's profiler pointed at one thing: 60% of the time was software floating point in the 3D renderer, which does everything in float. The 9500 has no FPU.

The renderer was rewritten in integers (tmd_fixed.inc), with 4.12 matrices like the PlayStation's GTE and a reciprocal table instead of division. That alone took the hall to 72–86 ms. The rest came from smaller things:

  • The frame limiter. The player alone took exactly 47 ms per frame, which is three ticks of the phone's 15.625 ms clock. Counting 33 ms from "now" always came out as 47, so the game ran at 70%. It now counts from when the tick was due.
  • Memory. A frame is 107 KB, and it was moving through memory four times. Now the room's background is put back only where the last frame drew, and depth is cleared only where triangles went.
  • Files. The opening text paused for eight seconds on its first letter. That was the first room loading in 2,387 reads of 1 KB each, because newlib's small C library ignores the buffer size it's given. With a 32 KB buffer it's 162 reads.
  • The log. Every line written to the log opened the file on the card, about 170 ms each, and a door printed a dozen lines. That was 2.4 seconds of every door. Log lines now wait in memory.

That brought the hall to 61 ms per frame. Free play, with only the player on screen, runs at full speed.

184 MB of sound

The PC version's sound effects and voices are 1,110 plain WAV files, 184 MB of them. The blob got an integer version of the decompilation's mixer, and the stub got Duke9500's audio thread, calling the blob's mixer whenever the stream needs data. It worked on the first try.

Then the log showed what it cost. Each sound file took three opens, about 50 ms each in a folder of 547 files, which added 4 to 5 seconds to every door. So sndpack on the PC packs all 1,110 sounds into one 53 MB file, SOUND.PAK, as IMA ADPCM, and the mixer decodes them as they play. The pack stays open and a door's sounds now load in a fraction of a second.

Next came lag: sounds started half a second to a second late. The game started them at once, so the time was spent in the pipe. Duke's audio thread worked out what had been heard from the clock, and the clock can't know when the stream really starts playing. So the stub now asks the stream how much it has actually played (GetBytes) and keeps about 140 ms queued. The catch: on the 9500 each GetBytes call takes 15 ms, so it's asked every other tick and the clock fills in between. My verdict was "The sounds are okay now I think." For me that's high praise.

Barry finds the blood by the fireplace, his lines read from SOUND.PAK.

The movies

The 27 movies are 320×240 Cinepak AVIs at 10 frames per second. The phone plays them as they are, straight from the card. movie.cpp decodes Cinepak to the screen's RGB565 directly and keeps the picture in step with the sound. When the first full build played the opening, my report was one line: "The amazing opening video works flawlessly!"

Then my memory card was full. The movies are 245 MB, and Doom, Quake and Duke wanted to stay. So shrink.py re-encodes them on the PC with ffmpeg: Cinepak again, but at 268×200, which is the size the phone shows anyway, and the sound as mono ADPCM like the pack. They come to 95 MB and look the same on the phone. They're also faster, because there's nothing left to scale.

Barry is too slow

Then the bug report that made me want to write this post: "A bit sluggish though, especially in the first action scene when Barry shoots the first zombie." The log said exactly how sluggish:

87 ms per frame: clear 41, 3D 642, present 47, files 0+0
98 ms per frame: clear 47, 3D 696, present 52, files 0+0

The numbers are tenths of a millisecond, so the 3D alone took 64–70 ms of each frame. One tick per frame means the scene ran at about 38% speed, with the voices running ahead of the people saying them.

The fix was already in the game. Its frame governor has a path for dropping a frame. The logic runs, and only the drawing is skipped:

g_frameTimeAccumulator += 100;
if (g_frameTimeAccumulator < g_frameTargetTime) {
    // Dropped frame: discard everything queued for it
    ...

The retail game never used it. On the 9500 it does now: when the next tick is already due, the current one is worked out but not drawn. At least one in four is drawn. To check it, armemu learned to run at the phone's speed, and every frame drawn with frame skip turned out identical to one from a run without it, just shown up to three ticks later.

The 3D got faster at the same time. The PC version gives every corner of every face its own vertex, so each point of a model was transformed once for every face around it. Each vertex buffer now gets a list of its distinct points, and only those are transformed. A slow loop in the rasteriser went too. The hall dropped from 2.99 to 2.56 million instructions per tick, and the pictures stayed identical, pixel for pixel, over hundreds of frames.

Each bar is from a re9500log.txt the phone wrote, in the hall while Wesker, Barry and Jill talk. The last one is full speed, drawing about 14 frames a second; Barry's scene draws about 10.

The next log said "It's running faster, but with lower fps but that's okay." The heaviest scenes now skip about two ticks in three, and the game keeps time.

35 ms per frame: clear 11, 3D 197, present 13, files 0+0, skipped 101

The day the sound stopped

Then one run went wrong in a new way. At the first zombie, the movie showed one frame and stopped. A key press skipped it, but then Barry just stood there, and Chr+Q restarted the whole phone.

audio at 167.046 s: paced by what the stream has played, keeping 417 ms (3 raises), ...
movie DM3.avi: 1 frames shown, 0 late, 1 decoded

The stub's audio thread had stalled three times and then stopped for good, most likely stuck in a call to the phone's media server. With nothing mixing, the movie's clock, which is its sound, stood still. And the game only marks a voice line finished when its sound reaches the end, so the cutscene waited forever for a line that never ended.

Now the game keeps an eye on the audio thread. If the stream takes nothing for a second, the blob mixes the sound itself, without playing it, so voices end and movies run on time, in silence until the stream comes back. A test in armemu with the sound cut halfway through the hall's cutscene ends on exactly the same tick as normal. The stub also logs which call its audio thread was in if it ever stops again.

Things the PC version didn't have

The game uses only 267 of the screen's 640 columns, so there's room around it. Like NES9500, RE9500 shows a background.bmp from its folder there. Mine is in the pictures.

The music was a little loud, so the in-game OPTIONS menu (A), which the decompilation added, got a SOUND page with separate MUSIC and SOUND volumes. You hear the change at once, because the room's music keeps playing under the menu.

The same menu can save and load anywhere, in 16 slots, with no ink ribbon needed. These are the typewriter's own files, so the title screen's LOAD GAME sees them too. Purists can still use the typewriter.


The Windows game's icon is the zombie from the corridor, but at 32×32 in 16 dithered colours. The movie of that same zombie turning round ends on its face in full colour, so the Desk icons are cut from that frame by a script, at 64×50 and 25×20.

About the pictures

Unlike in the earlier posts, these aren't from a PC build. Every frame here is the real re9500.rbl, the same file the phone runs, executed instruction by instruction in armemu. The pictures are the phone's whole 640×200 screen, background included. The emulator walked Jill from the title screen through the hall, the dining room and Barry's blood scene into the corridor, using a script of key presses timed in game ticks.

What's missing

  • I haven't played it through yet. The parts I have played work, and armemu has walked through all seven stages with random key presses, 108 room loads without a fault, but that is far from a playthrough.
  • Busy scenes draw about 10 frames a second. The game keeps its speed, but it's not smooth.
  • The title screen has no OPTIONS entry: it needs a font file that the decompilation's own tools make. The in-game menu (A) has everything.
  • Only the US version of the data has been tried.
  • If the phone's sound gets stuck in the media server, the game carries on in silence until the sound comes back.

The controls are the game's own: arrows or the rocker move, the rocker's middle, Enter, Space or C act, X aims, V or Ctrl runs and cancels, Z opens the status screen and A opens the options. Chr+Q quits.

You need the PC game. Copy everything in its HORR\USA folder except MOVIE, SOUND and VOICE to \RE9500\USA\ on the memory card. Then make SOUND.PAK from the SOUND and VOICE folders with sndpack, and if you want the movies, shrink the MOVIE folder with shrink.py. Both tools run on the PC and come with the source, and the readme has the steps. All of it is about 320 MB on the card with the shrunk movies.

Thanks to ecruells and everyone behind the Resident Evil decompilation, without which none of this exists; to Capcom for the game; and to the ffmpeg people for a Cinepak encoder in 2026.

The PC box asked for a Pentium and Windows 95. The 9500 has an ARM at 150 MHz with no floating point, a screen three times wider than it is tall, and a keyboard. It plays the whole opening, movies included, in a coat pocket. You were almost a Jill sandwich.

I'll put the game pack here when it's finished.

maanantai 9. marraskuuta 2026

CommDoom: the long fight for a fast Doom on the Nokia 9210 Communicator

Doom9500 took a day. I said so in that post, and I also said that it wasn't really a day: it was a day plus a month of earlier suffering on a different phone. This is the post about the suffering.

CommDoom is PrBoom 2.2.6 on the Nokia 9210 Communicator: a real 640×200 picture instead of a doubled 320×200, with sound effects, the screen melt, a launcher, and about 3.7 MB of RAM to do it all in. If you read the Doom9500 post, this is the port it calls Doomunicator. That was the working name until I noticed that it was too long for the Desk.

As with the others, 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 difference is that this time neither of us knew anything yet. Almost every rule that made the later ports quick was found here, usually by getting it wrong first.

Where it starts

The 9210 already has a Doom. Hannu Viitala and Markus Mertama's CDoom showed in 2001 that it could be done, and WildPalm sold a commercial one. I wanted the fastest one that could be made, with sound, which CDoom never had.

Claude picked PrBoom 2.2.6 as the engine, because it's the last version before PrBoom's renderer was rewritten for truecolour, consoles and filters. It still has the classic 8-bit renderer, with years of optimisation in it. CDoom's platform code turned out to be plain SDL on top of Hannu's SDL port for EPOC, so the engine could be swapped underneath it. It sounded easy. These are the main files that had to be written anyway:

FileLinesWhat it does
epocsnd.cpp1241Sound thread, one kept-alive player per effect, the chaingun
r_drawasm.c232ARM assembly: wall columns, floor spans, fixed-point maths
winsrt.c220Compiler helpers for the emulator build, which has no C runtime
armrt.c17164-bit helpers that this SDK's egcc.lib lacks
o2.bat79Sneaks -O2 past a build system that doesn't allow it
Launcher\*600The app on the Desk

Ten builds to link

The first emulator build took ten attempts. A selection from the list:

  • ftd_conf.h not found. The SDK's own assert.h includes it. It belongs to a Nokia-internal "field trial diagnostics" build and was never shipped. That's very likely why CDoom doesn't use assert.h anywhere. PrBoom does, so it got an empty stub.
  • io.h not found. The emulator build is compiled with Visual C++ and defines _WIN32, but its C library is a POSIX-style EPOC one. PrBoom's Windows code had to be fenced off, file by file.
  • __alldiv, __allmul, __chkstk unresolved. There's no compiler runtime either, so 64-bit maths doesn't link. Claude wrote those helpers in naked assembly from memory, which is what winsrt.c is. Remember that one.
  • signal unresolved. This libc doesn't have it, and at -O0 GCC 2.9 keeps unused static functions, so even a handler nobody installs needs its whole body removed.
  • __divdi3 unresolved, on the ARM this time, followed by __ucmpdi2. This SDK's egcc.lib has none of libgcc's 64-bit helpers, so armrt.c has the whole family, and FixedDiv was rewritten with 32-bit operations only.

The crash that wasn't

It linked. It ran, and it was playable, and after about five seconds it closed itself and File Manager came up. No panic, no error, nothing in the log. It happened every time, at the same moment, whatever I did.

The five seconds are also the length of Doom's title screen, so the first suspect was the first 3D level. winsrt.c did have a bug: its _chkstk left big stack frames four bytes short. That was a real bug and a good fix, and the crash stayed. Then the 6 KB of clip arrays on the stack went, and the crash stayed. -nosound: stayed. A stack report said we had a full megabyte, and it was the wall clock that mattered, not the game's tics. Stage logging narrowed it to the event pump in the SDL driver, and the event log showed that every handler returned. So nothing was crashing. Then the timestamps on the log came in (the emulator's ticks, about a dozen per second):

71 event begin type=10
71   focus group now=10
72   our group id   =5
...
73 DirectUpdate: NOT focused, focus group=12
74   waiting for focus (1s sleep), focus group=12
89   waiting for focus (1s sleep), focus group=12
100   waiting for focus (1s sleep), focus group=12
111   waiting for focus (1s sleep), focus group=12
123   waiting for focus (1s sleep), focus group=12

The game was alive the whole time. Doom isn't a registered application, and about five seconds after launch the Series 80 shell (Shell0) took the foreground and started File Manager (Fileman). SDL's video driver then politely waited for focus to come back, one second at a time, forever. On the screen that looks exactly like a clean exit.

The fix is two lines in the window setup, plus staying in front afterwards:

EPOC_WsWindowGroup.SetOrdinalPosition(0, ECoeWinPriorityAlwaysAtFront);
EPOC_WsWindowGroup.EnableReceiptOfFocus(ETrue);

My bug report: "Wow it actually stays up!" That AlwaysAtFront line is still there in Doom9500, Quake9500 and Duke9500.

"No application installed"

Then the phone. It refused:

Sovellusta ei ole asennettu tiedostolle MMC\DOOMUNICATOR.EXE.
Tätä tiedostoa ei voi avata.

"The application isn't installed for this file. It can't be opened." My first 9210i couldn't start even the original CDoom, so that unit was faulty, and I got another. The new one started Hannu's CDoom.exe straight from File Manager, and it gave ours the same message as before. There was no log either, so main was never reached.

A comparison of the two EXEs answered it. Their headers were the same kind. The difference was in the imports: Hannu's has five DLLs, ours had two more, and one of them was MEDIACLIENTAUDIOSTREAM.DLL. That was the streaming audio library our SDL sound backend used, a backend I'd spent days on in the emulator, because the emulator has that DLL. The phone's ROM doesn't. And when an EXE can't load, File Manager reports it as "no application installed".

Claude made a variant without sound, with only Hannu's five imports. It's still called doomnosnd.exe today, even though it has had sound for a long time. I put it on the phone and reported in capitals: "BUT IT RUNS CLAUDE. It's a giant milestone already!"

3.7 megabytes

A freshly restarted 9210 has about 3.7 MB of free memory, and that's for everything including the program itself. Claude's estimate of what the emulator build needed was 7.5 to 9 MB. So before the first real test, the game went on a diet:

  • MAX_SCREENWIDTH 1600 went to 320 (later 640). A single visplane has arrays that size, so each one shrank from 6.4 KB to about 1.3 KB, and about 15 other arrays shrank with them.
  • The zone went from a fixed 6 MB to whatever free RAM allows, minus a reserve. The first log from the phone had a 1.7 MB zone, later about 2 MB.
  • The screen melt and the translucency map were switched off, the melt being two extra screens. The melt came back later.
  • The stack went from 500 KB to 64 KB, and the sound mixer's volume table from int to short.

The first log showed a whole session from title screen to E1M1 and a clean exit, with no zone errors and 1.1 MB left unused. All that worry, and then it had room to spare.

Speed, the first round

That log also gave the first number: about 9 fps in a level. The next builds added proper profiling, one line per second, and the renderer turned out to take about two thirds of each frame. This is a later example at 640×200, when the log had grown some details:

PROF 8.7 fps | ms/frame: total=105 logic=8 render=78 blit=16 other=1 | per frame: colpix=82112 cols=1098 spanpix=54358 spans=441

Here's what makes the 9210 special. Its SDK compiles everything at -O0, with the optimisation flag commented out in the toolchain's own scripts. The project file can't set compiler flags. So at first, every global variable in the innermost loop was read from memory again for every pixel, and everything that followed was about that:

  • Hand-tuned loops first, with everything in register locals, and then ARM assembly for the wall columns, floor spans and the blit. The blit went from about 19 ms to about 8.
  • Low detail, half the columns, which got E1M1 to 22–27 fps at 320×200 and later became automatic: it drops down when frames go over 120 ms and returns under 55.
  • Process priority. The SDL startup code ran the game at Low priority with the thread at MuchLess, which made it stall whenever anything else wanted to run. It's Foreground and Normal now.

Then I asked for the whole screen. Native 640×200 costs a lot, and it brought the numbers back down to 8–9 fps on high detail in a big hall and 15 or more on low. But it's a real 640×200 picture, and walls have detail you don't see in a doubled image. My report from the phone: "it's MUCH faster than Hannu's port, even with sounds!"

Sound with one voice

With the streaming library gone, the only thing proven on the phone was the plain player that Cinema uses: CMdaAudioPlayerUtility, which plays a file. It also accepts a WAV in memory, so each Doom effect got a 44-byte WAV header and a player of its own, in a separate thread that has an active scheduler. The first test:

"There IS sound, but with some 1-2-second lag."

The sound log explained why, and it told us something worse:

  • Creating a player takes 0.3 to 1.3 seconds. That was all of the lag.
  • The errors said -14, KErrInUse, every time a new sound started. The phone plays one sound at a time. A new one cuts off the old one.

So the design turned around. The sound thread keeps players alive, keyed by effect, and only creates each one once. The ten most common are created at startup, so the first shot doesn't lag. With one voice something has to lose, so each effect has a priority class: your own weapons beat your other sounds, doors and items, and those beat monsters. "Pistol audio now starts almost at the same time I press fire."

Then the fights. A player cut off by the system is unusable until it's rebuilt, and that's a second of media server CPU, in the middle of a battle. Stopping the old sound ourselves first keeps the player valid, so the rebuild storms ended there. The last sound experiment was music, through the player and Doom's MUS converted to MIDI. The 9210's media server has no MIDI codec: -5, KErrNotSupported. So the game has no music, and it would have had to share that one voice anyway. "Perfect, dropping the music was a good call," I said.

The key clicks are a sound too, and they compete for that one voice. There's no API in this SDK to turn them off, so I turned them off in the phone's profile settings.

"Muisti ei riitä"

"Not enough memory." I could play once. The second run failed, even right after a reboot. The Switcher showed about 870 KB free after quitting, and three to five minutes later all of it came back. As a sanity check I ran Hannu's CDoom and watched it: it gave all the RAM back at once.

Claude wrote SysDump, a little tool that lists every process, thread and memory chunk on the phone, so I could run it before and after. The answer was that the C library's first write to stdout or stderr opens a console for the program. And Doom printed things, as Doom does, right down to the ENDOOM screen on exit. That console then kept the whole process alive for minutes after the game had quit, with its memory. CDoom never touched stdout. On the phone we don't either now, and after that fix I wrote: "Bravo Claude, just bravo!"

The launcher, the 16 MB card and the bunny

A launcher came next: an app for the Desk that starts the game and exits, to give back its memory. The picture on it is mine, made with Doom's own font:

It also got an Args button for a reason. Options used to live in a doomargs.txt, but PC Suite converts .txt files on their way to the phone, and my 16 MB MMC is so old that card readers can't read it. Now the launcher writes the file itself, on the phone. That meant I could type -file psxsound.wad, and the PlayStation sound effects played. They're twice as long as Doom's, so anything over 1.85 seconds gets cut and faded to fit the 20 KB buffers, and every next sound cuts them off anyway. That itch is what Doom9500 later scratched properly.

Then some smaller things:

  • The detail key. It was L, which meant that the IDBEHOLDL and IDCLEV cheats couldn't be typed. The letters g, j, x and z aren't in any cheat, so it's Z now.
  • The bunny. I reached Inferno's ending, and the bunny scroll was "veery sluggish", at about a second per frame. It redrew two full-screen pictures through the general stretching code every frame. Now each picture is rendered once into a buffer, and each frame is just two memcpys per line. "Works perrfectly!"
  • The melt. It needs a transposed copy of two screens, and at 640×200 PrBoom's way of doing that would ask the zone for 128 KB of contiguous memory at the worst moment. Now it's written straight into two buffers that exist only during the melt. If there's no memory for them, you simply don't get a melt, instead of an error.


The melt across the whole 640 pixels. I wasn't giving this one up.

A package from WildPalm

Around this time I asked WildPalm if I could buy the source of their Doom 9210. Nigel dug it up from old hard drives and gave me a development snapshot for free, which was very decent of him.

Claude went through it. The snapshot is from a later Series 60 retarget, so the 9210 video code is only there as commented-out leftovers. Their sound API has the same shape as ours (load by effect id, replay by id, stop by id), which was reassuring: we'd arrived at the same place independently. But the bigger news came from my own comparison on the phone. WildPalm's port doubles 320 pixels to 640, and it runs at about the same speed as our real 640. I wrote: "Our CommDoom really is the best port there is." To be fair, we have twenty years of hindsight, no deadline, and I'm not trying to sell anything.

Then I asked if there was anything left to optimise.

The last drops

Claude had already told me that what's left was small. "Heck, let's try squeezing the last drops," I said, and we spent a whole day on it. The test spot was the start hall of E1M1 on high detail, with an A/B key at first, to swap the wall code while standing still:

StepRenderFrame rate
Where the day started109.7 ms6.7 fps
Wall loop rewritten: register locals, texture lookup inlined, calls the assembly directly101.3 ms7.1 fps
FixedMul and FixedDiv in assembly, everywhere95.0 ms7.6 fps
SlopeDiv in assembly, r_segs.c at -O288.9 ms7.9 fps
All six renderer files at -O283.3 ms8.3 fps

That's a quarter more frames, on a game that had already been through all the above. On low detail the same spot went from about 8.1 to about 11.2 fps, once r_draw.c joined in.

FixedMul is the satisfying one. At -O0 the C version is a real function call with 64-bit library helpers, and in assembly it's four instructions:

AsmFixedMul:
    smull   r2, r3, r0, r1
    mov     r0, r2, lsr #16
    orr     r0, r0, r3, lsl #16
    mov     pc, lr

Doom's demos are recordings of key presses, and they only stay in sync if every calculation gives the very same result. So these routines have to be exact to the last bit. The SDK's ARM compiler doesn't run on my Windows 10 host, so nothing could be run there. Instead, Claude simulated the assembly bit by bit in a PowerShell script and compared it with the C version: 6109 cases for FixedDiv and 8143 for SlopeDiv. That caught one difference, for INT_MIN, which now goes to the C version.

The -O2 part took some cunning. The project file can't set flags, and the toolchain's global -O2 had once glitched the walls. But a build leaves its object files in a known folder. So o2.bat recompiles chosen files with the makefile's own command line plus -O2 -fno-strict-aliasing and drops the objects over the old ones, and the next abld build sees that they're up to date and only links. We did it in groups, and I watched for broken walls. For the 19 game logic files the test suite was free: let a title demo play, and if the marine walks into a wall, something's wrong. It played correctly, and the old wall glitch never came back, so it must have come from a file we never touched this time.

The -O2 experiment also saved us a lot of work. The plan was to write the whole wall loop in assembly next. But compiling that file at -O2 only took the loop from 36.5 to 33.8 ms, which showed that most of the rest is waiting for memory: texture columns, tables and pixels 640 bytes apart. Hand-written assembly wouldn't have changed that, so we didn't write it.

Here's the whole project in one picture. Every point comes from a log the phone wrote.

The drop on Sep 21 isn't a step backwards: it's me asking for the whole screen. Blue is the average of a whole run at 320×200. Green and orange are one identical frame, the first view of E1M1, which the logs let us pick out by its exact pixel counts, so those two lines compare like with like. October 3 also starts below where Sep 21 ended. That evening's numbers rest on only a few seconds of log, and we never tracked down the difference.

The barrels are too fat

While I stood in that hall for the measurements, I finally looked at it. The barrels were about twice as wide as they should be. And when I walked up to the dead marine in front of the door, the corpse vanished at about two metres, every time.

Claude asked whether anything had changed in the sprites, and that made me a bit jumpy, but it wasn't that day's code at all. PrBoom's vertical scale assumes a 4:3 monitor. On a 640×200 screen with square pixels, the horizontal scale comes out twice the vertical one, so the whole world is twice as wide as it should be. Every doubled port on this screen looks like that, and after a while you stop noticing. The explanation got the reply: "That is sooooooo true!"

Top: wide. Bottom: true proportions, on J. Compare the little barrel at the far end of the corridor. The price is a much narrower view up and down, and the near barrel is already half off the screen.

The corpse was the same mismatch causing a real bug. PrBoom's sprite height clip tests whether a sprite is above or below the view, and it used the horizontal scale for that vertical test. On a 4:3 monitor those are the same number. Here, close objects on the floor were thrown away while still visible:

yscale = FixedDiv(projectiony, tz);
if (thing->z > viewz + FixedDiv(centeryfrac, yscale) ||
    gzt      < viewz - FixedDiv(centeryfrac-viewheight, yscale))

True proportions went on the J key, with wide as the default, because honestly: "The default proportion is wide, it looks awesome".

The chaingun

I'd complained about this one back on the first day of sound: "Chaingun doesn't sound like chaingun, but pistol." It went on the list and stayed there until the very end, when I recorded it from the emulator and sent the WAV to Claude.

Both drawn from my recordings, 1.4 seconds each. Top: the old way. Bottom: the new rattle, in the version with the final shot.

The top half shows what was wrong. The gun fires every 114 ms, and restarting a player takes about 80 ms before any sound comes out. So you get about 25 ms of each shot, and then the next shot stops it. The long shot at the end is simply the last one, the only one allowed to finish.

One voice can't overlap shots, but nothing stops it from playing a sound that already has overlapping shots in it. So at startup the sound thread mixes eight pistol shots exactly four game tics apart into one loop. The tails of the last shots wrap around to the start, which makes it seamless:

for (TInt s = 0; s < shots; s++)
    {
    TInt j = i - s * period;            // shot s starts at s*period; this is its sample under i ...
    if (j < 0)
        j += len;
    for ( ; j < n; j += len)            // ... and every later wrap of a shot longer than the loop
        sum += (TInt)pcm[j] - 128;
    }

The first shot starts the loop with SetRepeats, and later shots only say "still firing". There's no restart, so there's no gap. It rattled at last. It also carried on, in my words, "waaaaay too long, like 3 seconds" after I let go.

The stop was a timer in the sound thread, measured in the system's ticks. It should have stopped after a third of a second. We never found out why it didn't, since the next idea made the question go away: why should the sound thread guess? A held chaingun fires exactly every four tics, so the game itself knows. Once a fifth tic passes with no shot, the firing has stopped:

if (epoc_loop_on &&
    (gametic - epoc_loop_tic > 4 || gametic < epoc_loop_tic || I_GetTime_RealTime() - epoc_loop_real > 17) &&
    EpocSndStopLoop())
  epoc_loop_on = 0;

Now it stopped on time, but it stopped dead, usually in the middle of a shot. So Claude had the normal pistol shot play right after the stop, to ring out like the last shot does in real Doom. That's the lower half of the picture, and you can see the problem. Switching to another player leaves 140 to 160 ms of silence, longer than one beat of the gun. The volume was a good match and the decay was right, but the gun now skipped a beat at the end of every burst. "The hitch bothers. Let's go back to the hard stop." So we did, and a comment in the code says why. Everything has its price on one voice.

Out of curiosity, I asked how WildPalm handled the chaingun. They don't, at all. Each shot is just the pistol sound, like in the original game. So how does that sound fine? Their sound engine, WildSnd, ships separately, and besides its DLLs its package installs a plugin into the phone's media server itself, linked against the server's internal audio libraries. As far as I can tell from those imports, it does its own mixing inside the server, where the one-voice rule doesn't apply. WildPalm solved back then the problem we'd spent a month working around. The SDK does contain the headers for such plugins. Their documentation page says: "The document is not available yet."

About the pictures

The game pictures aren't from the phone. They come from the PC build of the Doom9500 fork made for that post: the same renderer, compiled for a PC, walked through id's demo by a script. I reduced the colours to the 9210's 4096. The waveforms are from my two recordings.

What's missing

  • Music. There's no MIDI codec on the phone, and only one voice to play it with.
  • Sounds at the same time. Fixing that for real means writing a media server plugin with no documentation.
  • The translucency map is still off, to save 64 KB and startup time.
  • Multiplayer. The netcode was left out, since as I told Claude, the 9210 uses HSCSD, which was decommissioned over a decade ago.
  • Final proof on the phone of the rattle's last version. I judged its sound in the emulator, where I could record it.

The controls are Doom's own: arrows move and turn, Ctrl fires, Space opens doors, Shift runs and Alt strafes. Z cycles the detail between High, Low and Auto, J toggles the proportions and = toggles the numbers overlay.

Install the SIS, then copy prboom.wad and your doom.wad, doom2.wad or the shareware doom1.wad to D:\Doom\ on the memory card. CommDoom 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 and the SDL port it stands on, to Kenneth Guy, whose 9210 game showed what assembly can do on this CPU, and to Nigel at WildPalm for digging through old hard drives for me.

In October my 9210 started shutting itself off at random, and it missed one of my alarms, so it has retired. CommDoom doesn't run on the 9500 that replaced it: it starts and draws a few pixels at the top of the screen. So it's a monument to one phone, which I think is fine. Everything that was learned here went on to Doom9500, Quake9500 and Duke9500, and they all took days instead of a month. But this is the one where I walked up to a dead marine on a Nokia 9210, at 640 pixels wide, on 3.7 megabytes, and the marine stayed there.

I'll put the game pack here when it's finished.

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.

I'll put the game pack here when it's finished.

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.

I'll put the game pack here when it's finished.