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:
| File | Lines | What it does |
|---|---|---|
epocsnd.cpp | 1241 | Sound thread, one kept-alive player per effect, the chaingun |
r_drawasm.c | 232 | ARM assembly: wall columns, floor spans, fixed-point maths |
winsrt.c | 220 | Compiler helpers for the emulator build, which has no C runtime |
armrt.c | 171 | 64-bit helpers that this SDK's egcc.lib lacks |
o2.bat | 79 | Sneaks -O2 past a build system that doesn't allow it |
Launcher\* | 600 | The app on the Desk |
Ten builds to link
The first emulator build took ten attempts. A selection from the list:
ftd_conf.hnot found. The SDK's ownassert.hincludes it. It belongs to a Nokia-internal "field trial diagnostics" build and was never shipped. That's very likely why CDoom doesn't useassert.hanywhere. PrBoom does, so it got an empty stub.io.hnot 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,__chkstkunresolved. There's no compiler runtime either, so 64-bit maths doesn't link. Claude wrote those helpers in naked assembly from memory, which is whatwinsrt.cis. Remember that one.signalunresolved. This libc doesn't have it, and at-O0GCC 2.9 keeps unused static functions, so even a handler nobody installs needs its whole body removed.__divdi3unresolved, on the ARM this time, followed by__ucmpdi2. This SDK'segcc.libhas none of libgcc's 64-bit helpers, soarmrt.chas the whole family, andFixedDivwas 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_SCREENWIDTH1600 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
inttoshort.
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
registerlocals, 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:
| Step | Render | Frame rate |
|---|---|---|
| Where the day started | 109.7 ms | 6.7 fps |
| Wall loop rewritten: register locals, texture lookup inlined, calls the assembly directly | 101.3 ms | 7.1 fps |
FixedMul and FixedDiv in assembly, everywhere | 95.0 ms | 7.6 fps |
SlopeDiv in assembly, r_segs.c at -O2 | 88.9 ms | 7.9 fps |
All six renderer files at -O2 | 83.3 ms | 8.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.












