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 E:\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.

Ei kommentteja: