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.

Ei kommentteja: