Running Bounce Evolution on modern Linux

Wed, 15 Feb 2023 12:00:00 +0000

Making 32-bit ARM binaries from 2009 work on modern 64-bit x86/ARM Linux using qemu user-space emulation, X11/EGL/D-Bus/PulseAudio wrapping and OpenGL pass-through.

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 (armhf).

Also, remember upstart, the init system? Yeah, there was no systemd 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 installing 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. The 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.

Library hooking

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 x86_64 or aarch64 Linux).

The way I've implemented this here is to LD_PRELOAD a small library into the 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 offset (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).

Anisotropic filtering

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.

Accelerometer

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.

Task switcher

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.

FPS counter

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.

Verbose logging

A debug printf() function exists in the binary, but it's stubbed out. With the --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!