This post introduces and describes some technical details about The Maemulator, a compatibility layer to run a game originally available on the N900 on modern Linux-based systems.
The Story So Far
Bounce Evolution is a tech demo released for the Linux-based Nokia N900 phone in 2009.
Two years ago, reBounce allowed running the game on the N9, a system similar to the N900 that only needed translation between softfp and hardfp ABIs, and accelerometer reading.
But it's 2023 now, our machines run x86_64 or even aarch64 CPUs (with RISC-V on the horizon?), Linux
has moved on from X11 to Wayland (mostly), and PipeWire slowly replaces
PulseAudio, and if you're not yet running 64-bit on your Raspberry Pi,
it's at least running on the 32-bit hard float ABI (
upstart, the init system? Yeah, there was no
back in the day, but upstart was the first real try at creating an
event-based replacement for traditional init, and speeding up the boot
process. Things became so much comfortable since then that these days
it's often easier to just write a systemd unit file compared to
supervisord for any custom system services. But I digress...
What Is A "Linux"?
"Linux-based" in this context means "proper glibc-based" Linux userspace (with Debian, X11, EGL, D-Bus, PulseAudio, ... - the whole Linux Desktop stack of the late '00s, basically), as opposed to Android, which is using the Linux kernel with Bionic libc and a custom userland.
Android's Bionic libc and (Desktop) Linux' glibc are not compatible, but apkenv will make some apps compiled against Bionic work on glibc-based systems, and libhybris will make glibc-based userland work on Android hardware adaptations, so it's just a matter of translating between the two ABIs, assuming the CPU architecture matches.
No such issues when going from Maemo 5 to modern multiarch Linux, though.
arm-linux-gnueabi triplet describes the 32-bit soft floating point
ARM ABI, and that is still supported in modern distros - glibc stays
API and ABI compatible
for the most part (this API report might not reflect the
armel ABI, but
it's a good indicator that the maintainers care about compatibility).
QEMU user-space emulation
So all that's needed to run 32-bit ARM ELF binaries for Linux on any other Linux system is QEMU user-space emulation, which translates syscalls from the guest to the host without needing to spin up a full system (the guest process lives in the host OS transparently).
That is, assuming that the application in question just uses Linux system calls and no libraries. And in our case, we do need to hook some libraries.
As mentioned above, the "Desktop Linux" technologies used back in the day come in the form of libraries such as libdbus, libpulse and libX11. The EGL API and OpenGL ES 2.0 are used for rendering content to the screen, so those two APIs need to be implemented as well.
Many functions can be dummy stubs, as we don't care about things such as low battery states or when the screen is turned off, or even task switching for now -- you play the game, you get out and that's it.
Talking with the host
As the point of this exercise is to actually show something on-screen, we
need to communicate from the guest (32-bit ARM binary running within the
qemu-user emulation) to the host (64-bit
The way I've implemented this here is to
LD_PRELOAD a small library
qemu-user process (host-side) that can create a bi-directional
communication pipe (aka "a pair of uni-directional UNIX pipes").
Since the user-space emulation provided by QEMU is really transparent and lightweight, the guest process (running within QEMU) can "see" and use environment variables and open file descriptors from the host process, thereby making it possible to communicate between the two.
Furthermore, QEMU maps the "guest" memory on the host, just with a slight
mmap_min_addr). What this means is that the "host" side of the
process can see any memory allocated/mapped by the "guest", thereby making
it possible to access the guest's memory directly.
A little bit of code generation magic and we have an easy way of calling library functions on the guest and having them executed on the host.
The host side
The host library takes care of bringing up a window (using SDL2) with an OpenGL context. It then carries out any GL calls on behalf of the guest, and forwards input events from the system to the guest for injecting from the fake libX11 API calls.
OpenGL ES, PVRTC and shaders
The game uses OpenGL ES 2.0 for rendering, and PVRTC is used for texture compression (as the N900 and N9 had PowerVR SGX GPUs that support it).
The Mesa-based OpenGL driver doesn't support PVRTC out of the box, so any PVRTC textures are decompressed to RGBA on the fly at load time, so that it works properly on any OpenGL implementation, even the ones not supporting PVRTC.
In a similar fashion, Desktop GL doesn't understand GLSL ES's precision
qualifiers by default, so when loading GLSL ES shader source code, the
code is prefixed with
"#version 100\n" to force the driver to interpret
the shader source as GLSL ES (including precision qualifiers).
Once we are there, and run the game in full screen, the rendering result looks quite good. But we can improve the rendering result even more by enabling anisotropic filtering. This is done by hijacking the texture filtering setup calls and injecting calls to enable the improved filtering.
Unmute audio on start
A known issue (and one I haven't looked into for reBounce back in 2021, but this is now fixed) was that the audio was always muted until you muted and then unmuted the audio in the main menu. In order to work around this issue, we can inject some events into the game's event stream that will toggle audio on when the game starts.
The game uses accelerometer input as its main way of controlling the protagonist. On PC, it makes sense to allow for keyboard input. We can take the arrow keys as input for rotating a 3D vector that represents the acceleration. With some tweaks, this is quite acceptable. For jumping, the SPACE key is used, and it just adds a long "up acceleration" vector to the reading, which the game interprets just fine.
The game has a task switcher button in the top left corner. Back in the
N900 days, this would have sent a D-Bus signal to Hildon Desktop and ask
it to bring up the task switcher view. While this isn't all that useful
on modern platforms, I added a
--nostalgia switch that will fake the
task switcher view when the button is pressed. Just for fun.
There's a special in-game event that will enable a FPS counter in the
bottom center of the screen. The
--fps flag will take care of that.
printf() function exists in the binary, but it's stubbed out.
--verbose command-line switch, the code of the function is
patched to jump to the libc
printf() function instead, re-enabling
debug output of the game.
Putting it all together
Check out the The Maemulator for the full implementation!