Porting Doom to a 20-year-old VoIP phone

Source: 0x19.co
19 points by lr0 7 hours ago on lobsters | 0 comments

This post is dedicated to my ADD and getting sidetracked at every possible step.

A while back, I was given several VoIP phones that were phased out at my old job, among which were two Snom 360 Business phones from 2005. My original plan was to set up an Asterisk PBX with the phones I’d collected. But then, while upgrading the firmware on one of the Snom 360s, I had a better idea. Since this phone had a screen and a keyboard … could I get Doom running on it?

1. Initial firmware upgrade

Since this model was released in 2005, the first thing I wanted to do was upload some new firmware onto the phone. Luckily, Snom provides an archive for old firmware images1. As far as I could tell, the latest firmware intended for the 3xx series of devices was V08, so the image I downloaded was Deskphone - Filearchive - V08 > snom360-8.7.3.7-SIP-f.bin.

Web Interface - Update

Firmware updates can be installed via the web interface running on the phone itself. You simply provide a URL to the firmware image, and the phone downloads and installs it on its own.

2. Exploring the firmware

Now, at this point I had no knowledge of what the phone was actually running, and how hard it would be to port Doom. I started my investigation by looking at the HTTP headers the web interface was sending back:

HTTP/1.1 200 Ok
Server: snom embedded
Content-Type: text/html
Cache-Control: no-cache
Cache-Control: no-store
Content-Length: 14018

The generic “snom embedded” label suggested they were running their own custom HTTP(S) server. Curious to learn more, I downloaded the firmware image and ran binwalk on it to get a rough overview:

$ binwalk snom360-8.7.3.25.9-SIP-f.bin

                                     /tmp/snom360-8.7.3.25.9-SIP-f.bin
----------------------------------------------------------------------------------------------------------
DECIMAL                            HEXADECIMAL                        DESCRIPTION
----------------------------------------------------------------------------------------------------------
16                                 0x10                               JFFS2 filesystem, big endian, 
                                                                      nodes: 2035, total size: 3377072 
                                                                      bytes
----------------------------------------------------------------------------------------------------------

Analyzed 1 file for 85 file signatures (187 magic patterns) in 47.0 milliseconds

The firmware image wasn’t encrypted and contained a JFFS2 filesystem—a format designed for flash memory devices2. I extracted it to take a look inside:

$ binwalk -e snom360-8.7.3.25.9-SIP-f.bin -C jffs2.img

                                /tmp/jffs2.img/snom360-8.7.3.25.9-SIP-f.bin
----------------------------------------------------------------------------------------------------------
DECIMAL                            HEXADECIMAL                        DESCRIPTION
----------------------------------------------------------------------------------------------------------
16                                 0x10                               JFFS2 filesystem, big endian, 
                                                                      nodes: 2035, total size: 3377072 
                                                                      bytes
----------------------------------------------------------------------------------------------------------
[+] Extraction of jffs2 data at offset 0x10 completed successfully
----------------------------------------------------------------------------------------------------------

Analyzed 1 file for 85 file signatures (187 magic patterns) in 333.0 milliseconds

binwalk gave me both the filesystem binary and the complete extracted filesystem:

$ ls jffs2.img
snom360-8.7.3.25.9-SIP-f.bin  snom360-8.7.3.25.9-SIP-f.bin.extracted

The extracted filesystem looked like a standard rootfs:

$ ls jffs2-root 
boot  dev  lost+found  mnt  proc  sbin  snomconfig  tmp  var

To learn more about the system, I checked what kernel the phone was running:

$ file boot/uImage
boot/uImage:
    u-boot legacy uImage,
    MIPS Linux-2.4.31-INCAIP-4.3,
    Linux/MIPS, OS Kernel Image (gzip),
    690926 bytes,
    Thu Jul  7 10:43:18 2011,
    Load Address: 0X80002000,
    Entry Point: 0X80180040,
    Header CRC: 0XCCC3CA0E,
    Data CRC: 0XE9FF8716

The phone was running Linux 2.4.31 on a MIPS chip. This already made me quite hopeful for the port, since I wouldn’t have to program for a completely unknown or bare-bones platform. Also interesting were were the /sbin and /mnt directories:

$ ls sbin mnt
mnt:
1lid  html  lcs360  moh.wav  snomlang

sbin:
init

The /mnt directory contained the HTML files for the web interface, along with two binaries: 1lid and lcs360.

$ file 1lid lcs360 
1lid:   ELF 32-bit MSB executable, MIPS, MIPS32 version 1 (SYSV), statically linked, for GNU/Linux 2.2.15, stripped
lcs360: ELF 32-bit MSB executable, MIPS, MIPS32 version 1 (SYSV), statically linked, no section header

Both were statically linked binaries compiled for MIPS32. The same was true for init:

$ file init 
init: ELF 32-bit MSB executable, MIPS, MIPS32 version 1 (SYSV), statically linked, for GNU/Linux 2.2.15, stripped

I poked around the strings in the init binary and found this interesting sequence:

$ strings init
[]
forking child
child alife, starting LID
/mnt/1lid
[]

So init was a custom binary that launched 1lid on boot. The strings in 1lid revealed more:

$ strings 1lid
[]
usage: lid
--device d: set audio device name (default is /dev/audio)
--host <host>: work as client (use this option for every possible host)
--keyboard d: set keyboard device name (default is /dev/kbd)
--display d: set display device name (default is /dev/snomdisp)
--port n: use socket n for communication (default is 1298)
/mnt/lcs360
--html-dir
/mnt/html/
[]

Since --html-dir didn’t appear to be a valid parameter for 1lid, it seemed that lcs360 was being called with --html-dir /mnt/html—making it the component responsible for hosting the web interface.

Finding the GPL source code

Now that I had a rough overview of what each binary did, I loaded them into Ghidra. This didn’t prove particularly fruitful, since all the symbols had been stripped and I didn’t have access to the libraries they were linked against.

Luckily, while browsing the Snom website, I stumbled upon the download area for their GPL-licensed components:

Snom Website - GPL Downloads

For some reason, there were no downloads for the v8 firmware, but v7 seemed close enough. On a sidenote, INCA-IP refers to a chip family by Infineon Technologies designed specifically for VoIP applications3, which I had already encountered in the kernel image from earlier (Linux-2.4.31-INCAIP-4.3).

I downloaded the following files:

  • Description and Rootfs
  • UPX Tool
  • Source code BusyBox
  • Source code Linux kernel and U-Boot for v7
  • uClibc Cross Compiler and Libraries for v7

These provided way more than I’d hoped for: the rootfs, kernel sources, busybox sources, a pre-compiled cross compiler, and even a tool for compressing binaries (UPX).

The v7 rootfs

The v7 rootfs was much more comprehensive than the one from the firmware image:

$ ls rootfs
bin  boot  dev  etc  inca_scripts  lib  lost+found  proc  sbin  tmp  usr  var

Instead of a custom init binary, this one was based on busybox:

$ ls -l sbin 
total 0
lrwxrwxrwx 1 root root 14 Mar  3  2008 ifconfig -> ../bin/busybox
lrwxrwxrwx 1 root root 14 Mar  3  2008 init -> ../bin/busybox
lrwxrwxrwx 1 root root 14 Mar  3  2008 insmod -> ../bin/busybox
lrwxrwxrwx 1 root root 14 Mar  3  2008 lsmod -> ../bin/busybox
lrwxrwxrwx 1 root root 14 Mar  3  2008 modprobe -> ../bin/busybox
lrwxrwxrwx 1 root root 14 Mar  3  2008 rmmod -> ../bin/busybox
lrwxrwxrwx 1 root root 14 Mar  3  2008 route -> ../bin/busybox

Alongside the rootfs, there was a readme file with instructions for generating v7 images for INCA-IP-based Snom phones. It covered:

  1. Configuring the kernel sources
  2. Building the uImage for uBoot
  3. Building a rootfs image
  4. Deploying the image via a serial line

Excitingly, the readme ended with these lines:

"See u-boot restarting. Linux starts up. Change serial line setup to 9600 8N1. Press Enter. 
And you will have a shell prompt." 

Admittedly, the thought of gaining shell access to my phone did make me pretty happy.

3. Building custom firmware and gaining shell access

I extracted the cross-compilation toolchain and kernel sources and started following the instructions, including my favorite one:

3. Configure for INCA-IP (ignore errors)

I tried building the kernel for a while, but eventually hit errors I couldn’t ignore. I was getting a bit discouraged since I’d never compiled a kernel from scratch before. While the prepackaged rootfs already contained a uImage, I instead extracted it from the latest v7 update (snom360-7.3.7-SIP-f.bin) to guarantee compatibility with my device.

To build the actual image, I used the mkfs.jffs2 binary provided in the GPL downloads:

# ./bsp_4.3/tools/build_tools/mkfs.jffs2 -b -r rootfs/ -o rootfs.jffs2.img

With the image built (the whole process was much quicker than expected), all I had left to do was flash it to the phone.

Finding the serial port

The readme only mentioned use serial console to deploy image via u-boot with TFTP which wasn’t exactly helpful. Hoping to find out more, I opened up the phone.

The phone had two separate PCBs: one in the top half, one in the bottom. The bottom board housed all the peripheral ports, memory, and CPU chips. The top PCB handled the screen and buttons.

While I’m not particularly well-versed in PCB design, I quickly spotted these test points on the top half:

The labels GND, TxD, and RxD looked promising. I soldered on some wires and connected them to a Serial-to-USB adapter. Unfortunately, this produced no output. However, there were a few more test points on the bottom PCB:

Interestingly, there were three holes in the phone’s housing directly below these test points, covered by a large sticker. Since they weren’t labeled, I took some measurements and identified the ground connection. I soldered on wires again and connected them to my adapter. With no way of knowing which was Rx and which was Tx, I simply tried both combinations. Luckily, this time I actually got some output:

# screen /dev/ttyUSB0 115200
U-Boot 1.1.3-m jffs2 (Apr 17 2007 - 12:29:17)

Board: INCA-IP Standard Version, Chip V1.4, CPU Speed 150 MHz
Watchdog aware version
DRAM:  16 MB
Flash:  4 MB
In:    serial
Out:   serial
Err:   serial
Net:   INCA-IP Switch
Hit any key to stop autoboot:  1 
DISPLAY fd = 0                                                                                                                                                                                                                              0 
INCA-IP-ROM #  

Flashing the image

With access to the U-Boot console, I could now flash my newly created image. First, I needed to set up a network connection for TFTP. The instructions were straightforward:

"setup the network for TFTP respectively:"

setenv netmask 255.255.0.0
setenv serverip 192.168.0.x
setenv gatewayip 192.168.0.y
setenv ipaddr 192.168.0.z

"If you want to see any linux output (the side car isn't working with this!):"

run console_on

"Switch of the output with:"

run console_off

"If you want to reduce the initial time the bootloader waits for a keypress:"

setenv bootdelay 1

"Finally write your changes to the flash."

saveenv

I configured the network settings and enabled console_on to see Linux output. I then started a simple TFTP server on my machine:

# sudo uftpd -n -o ftp=0,tftp=69 img/

and downloaded the image onto the phone:

INCA-IP-ROM # tftpboot 80400000 rootfs.jffs2.img
Using INCA-IP Switch device
TFTP from server 10.20.30.40; our IP address is 10.20.30.50
Filename 'rootfs.jffs2.img'.
Load address: 0x80400000
Loading: #################################################################
         #################################################################
         #################################################################
         #################################################################
         #################################################################
         #################################################################
         #############
done
Bytes transferred = 1713156 (1a2404 hex)

Finally, I flashed it to memory. All 3xx phones except the 370 have 4 MB of flash memory:

INCA-IP-ROM # erase b0040000 b03fffff

........................... done
Erased 60 sectors
INCA-IP-ROM # cp.b 80400000 b0040000 1a2404
Copy to Flash... done
INCA-IP-ROM # reset

4. What’s running on the phone?

After the phone rebooted, I connected through the serial interface again:

# screen /dev/ttyUSB0 9600

#

Since I now had shell access, I wanted to find out what kind of hardware the phone was running. I verified the kernel again:

# uname -a
Linux 10.20.30.50 2.4.31-INCAIP-4.3 #1 Wed Feb 20 00:41:41 CET 2008 mips unknown

The phone was indeed running the custom INCAIP 2.4.31 kernel I’d extracted from the firmware image.

# df
Filesystem           1k-blocks      Used Available Use% Mounted on
/dev/mtdblock2            3840      1996      1844  52% /
tmpfs                     7100         0      7100   0% /tmp
# busybox
BusyBox v1.2.2 (2007.02.22-17:43+0000) multi-call binary

Usage: busybox [function] [arguments]...
   or: [function] [arguments]...

        BusyBox is a multi-call binary that combines many common Unix
        utilities into a single executable.  Most people will create a
        link to busybox for each function they wish to use and BusyBox
        will act like whatever it was invoked as!

Currently defined functions:
        [, [[, ash, busybox, cat, chgrp, chmod, chown, chroot, cksum,
        cp, df, du, echo, egrep, false, fgrep, free, grep, halt, ifconfig,
        init, insmod, kill, linuxrc, ln, ls, lsmod, mkdir, mknod, modprobe,
        mount, mv, ping, pivot_root, poweroff, ps, pwd, reboot, rm, rmmod,
        route, sh, sleep, stty, sync, tar, test, true, tty, umount, uname,
        uptime, yes

The shipped busybox didn’t contain as many functions as I would’ve liked. The 7x firmware images seem to use the busybox-provided init function, which reads from /etc/inittab upon booting:

# cat /etc/inittab 
# This is run first except when booting in single-user mode.
::sysinit:/etc/rc.sh

# /bin/sh invocations on selected ttys
#
# Must be first 'respawn' entries to avoid ^C problem
# Start a shell on the console
::respawn:-/bin/busybox sh
# Start an "askfirst" shell on /dev/ttyS1
#ttyS1::askfirst:-/bin/sh

#
# Start internet super daemon; do NOT background!
#::respawn:/usr/sbin/xinetd -stayalive -reuse -pidfile /tmp/xinetd.pid

# Start user application

::sysinit:/bin/stty -F /dev/ttyS0 -icanon 
::sysinit:/bin/stty -F /dev/ttyS0 -echo
::sysinit:/bin/stty -F /dev/ttyS0 -icrnl
::sysinit:/bin/stty -F /dev/ttyS0 clocal
::sysinit:/bin/stty -F /dev/ttyS0 hupcl
::sysinit:/bin/stty -F /dev/ttyS0 9600
#::sysinit:/bin/stty -F /dev/ttyS0 115200

Building a better busybox

Since the shipped busybox was too limited for what I had in mind, I decided to build a custom one using the source code Snom provided. I’d never built busybox from scratch before, but it turned out to be straightforward. I included everything from the original image plus several quality-of-life improvements and an FTP client for easier file transfers. For convenience, I compiled it as a static binary, which made the file significantly larger:

$ du -s busybox
1544	busybox

Fortunately, the GPL downloads included a upx4 binary for compression:

$ ../upx-3.03-i386_linux/upx -9 busybox                                                    
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2008
UPX 3.03        Markus Oberhumer, Laszlo Molnar & John Reiser   Apr 27th 2008

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
   1579596 ->    480008   30.39%  linux/mipseb   busybox                       

Packed 1 file.

This brought the file size down to something much more manageable. I copied the binary to /bin in the rootfs directory, then rebuilt and reflashed the image. The new busybox had significantly more functionality:

~ $ busybox
BusyBox v1.2.2 (2026.01.29-21:33+0000) multi-call binary

Usage: busybox [function] [arguments]...
   or: [function] [arguments]...

        BusyBox is a multi-call binary that combines many common Unix
        utilities into a single executable.  Most people will create a
        link to busybox for each function they wish to use and BusyBox
        will act like whatever it was invoked as!

Currently defined functions:
        [, [[, addgroup, adduser, adjtimex, arping, ash, awk, basename,
        bbconfig, busybox, cal, cat, catv, chattr, chgrp, chmod, chown,
        chroot, chvt, cksum, clear, cmp, comm, cp, crond, crontab, cut,
        date, dc, dd, deallocvt, delgroup, deluser, df, diff, dirname,
        dmesg, dnsd, dos2unix, du, dumpkmap, dumpleases, e2fsck, e2label,
        echo, ed, egrep, eject, env, ether-wake, expr, fakeidentd, false,
        fbset, fgrep, find, findfs, fold, free, freeramdisk, fsck, fsck.ext2,
        fsck.ext3, fsck.minix, ftpget, ftpput, fuser, getopt, getty, grep,
        halt, hdparm, head, hexdump, hostid, hostname, httpd, hush, hwclock,
        id, ifconfig, ifdown, ifup, inetd, init, insmod, install, ip,
        ipcalc, ipcrm, ipcs, kill, killall, klogd, lash, last, length,
        less, linux32, linux64, ln, loadfont, loadkmap, logger, login,
        logname, losetup, ls, lsattr, lsmod, makedevs, md5sum, mdev, mesg,
        mkdir, mke2fs, mkfifo, mkfs.ext2, mkfs.ext3, mkfs.minix, mknod,
        mkswap, mktemp, modprobe, more, mount, mountpoint, msh, mt, mv,
        nameif, nc, netstat, nice, nohup, nslookup, od, openvt, passwd,
        patch, pidof, ping, pipe_progress, pivot_root, poweroff, printenv,
        printf, ps, pwd, rdate, readlink, readprofile, realpath, reboot,
        renice, reset, rm, rmdir, rmmod, route, run-parts, runlevel, rx,
        sed, seq, setarch, setconsole, setkeycodes, setlogcons, setsid,
        sh, sha1sum, sleep, sort, start-stop-daemon, stat, strings, stty,
        su, sulogin, sum, swapoff, swapon, switch_root, sync, sysctl,
        syslogd, tail, tee, telnet, telnetd, test, tftp, time, top, touch,
        tr, traceroute, true, tty, tune2fs, udhcpc, udhcpd, umount, uname,
        uniq, unix2dos, uptime, usleep, uudecode, uuencode, vconfig, vi,
        vlock, watch, watchdog, wc, wget, which, who, whoami, xargs, yes,
        zcip

5. Preparing to port Doom

With shell access and a custom busybox, I was now one step closer to running doom (that’s what this post is actually about, remember?). Now, at this point I was wondering about how I would actually go about porting doom. Do people just modify the original source code? Do they port the engine? In my research, I found doomgeneric5, which is a fork of fbDOOM designed for easy portability. To port it, you only need to implement a handful of functions:

Functions Description
DG_Init Initialize your platform (create window, framebuffer, etc…).
DG_DrawFrame Frame is ready in DG_ScreenBuffer. Copy it to your platform’s screen.
DG_SleepMs Sleep in milliseconds.
DG_GetTicksMs The ticks passed since launch in milliseconds.
DG_GetKey Provide keyboard events.
DG_SetWindowTitle Not required. This is for setting the window title as Doom sets this from WAD file.

Since we were working with a Linux-based system, I could reuse SleepMS and GetTicksMs from the reference Linux implementation. The two things I needed to figure out were:

  • How to draw to the screen
  • How to get input from the keypad

6. Reverse engineering the driver

To figure out how the screen and keyboard worked, I needed to dive deeper into the firmware.

Note: This section is extremely technical and contains a lot of decompiled code. It details the process of me reverse engineering the drivers for the display, LEDs and keyboard. If you’re not interested in that, feel free to skip ahead to section 7.

The v7 binaries weren’t completely stripped and contained quite a few external symbols:

nm -gD 1lid | wc -l  
860

I loaded it into Ghidra again, this time pointing it at all the libraries provided with the toolchain at opt/uclibc-toolchain/gcc-3.3.6/toolchain-mips/lib. I let the automatic analyzer run and started analyzing the decompiled sources. Note that I’ll only outline the code pertinent to the problem at hand. Furthermore, I have already renamed mangled variables and functions for better readability.

Drawing to the display

I started by examining the main function, which assigned the value /dev/inca-port to a variable:

port_name = "/dev/inca-port";
bDisplayOn = 1;
sleep(1);

This variable was then manipulated based on the arguments passed to the executable. If none were passed, it retained its default value. A bit later, it was used in a function call that seemed to be related to the display:

SetDevice__13MatrixDisplayPCc(theDisplay,port_name);

Since the exported symbols were preserved, figuring out what each function did was much easier.

void SetDevice__13MatrixDisplayPCc(int display_fd,char *name)

The function began with a series of calls to set up the file descriptor for the display:

Clear__11RasterImage();
uVar1 = open_device__3TLDPCc(dev);
*(undefined4 *)(display + 0x18) = 0xf;
init_snom_display__3TLDv();
port_set_fd__Fi(uVar1);

The device path (char *dev) was passed to open_device, which simply opened the file without any permissions:

display_fd = open(path,0);

Note that display_fd is a global variable used throughout the code. After opening the file descriptor to /dev/inca-port, the display was initialized:

printf("DISPLAY fd = %d\n",display_fd);
setup_display(1);
hide_arrows_snom_display__Fv();
DAT_10015fd4 = 0;

I took a closer look at the setup function. It contained quite a lot of code, so I’ll go through it bit by bit. It appeared to be responsible for initializing the port to talk to the display.

  if ((DAT_10015fd0 != 0) || (param_1 != 0)) {
    DAT_10015fd0 = 0;
    port_write__Fiiii(display_fd,2,10,1);
    port_write__Fiiii(display_fd,2,0xb,1);
    port_write__Fiiii(display_fd,2,6,1);
    port_write__Fiiii(display_fd,2,9,1);
    port_write__Fiiii(display_fd,2,8,1);
    if (param_1 != 0) {
      port_write__Fiiii(display_fd,2,8,0);
      iVar2 = 1;
      do {
        bVar1 = iVar2 < 100000;
        iVar2 = iVar2 + 1;
      } while (bVar1);
      port_write__Fiiii(display_fd,2,8,1);
      goto LAB_00453a44;
    }
  }
  inca_port_write_display_cmd(0xe2);

There were several writes to the port at display_fd (the global variable from earlier). Looking at how those writes took place:

void port_write__Fiiii(int param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4)

{
  parm = param_2;
  DAT_1003f578 = param_3;
  DAT_1003f57c = param_4;
  ioctl(param_1,0x800cbf05,&parm);
  return;
}

All the communication took place over ioctl calls! To decipher those, I took a step back. Bundled with the GPL downloads were kernel sources, which contained headers for an INCAIP kernel module:

$ ls bsp_4.3/source/kernel/ifx/bsp/include/asm-mips/incaip
dma.h  inca-ip.h  incaip_iom2_api.h  incaip_ssc.h  incaip_switch_api.h  irq.h  keypad.h  ledmatrix.h  model.h  mux.h  port.h  pwm.h  serial.h

The file port.h contained several ioctl definitions:

#define INCA_PORT_IOC_MAGIC	0xbf
#define INCA_PORT_IOCOD		_IOW(INCA_PORT_IOC_MAGIC,0,struct inca_port_ioctl_parm)
#define INCA_PORT_IOCPUDSEL	_IOW(INCA_PORT_IOC_MAGIC,1,struct inca_port_ioctl_parm)
#define INCA_PORT_IOCPUDEN	_IOW(INCA_PORT_IOC_MAGIC,2,struct inca_port_ioctl_parm)
#define INCA_PORT_IOCSTOFF	_IOW(INCA_PORT_IOC_MAGIC,3,struct inca_port_ioctl_parm)
#define INCA_PORT_IOCDIR		_IOW(INCA_PORT_IOC_MAGIC,4,struct inca_port_ioctl_parm)
#define INCA_PORT_IOCOUTPUT	_IOW(INCA_PORT_IOC_MAGIC,5,struct inca_port_ioctl_parm)
#define INCA_PORT_IOCINPUT	_IOWR(INCA_PORT_IOC_MAGIC,6,struct inca_port_ioctl_parm)
#define INCA_PORT_DISPCMD	_IOWR(INCA_PORT_IOC_MAGIC,7,struct inca_port_ioctl_parm)
#define INCA_PORT_DISPDATA	_IOWR(INCA_PORT_IOC_MAGIC,8,struct inca_port_ioctl_parm)
#define INCA_PORT_GS_DISPCMD    _IOWR(INCA_PORT_IOC_MAGIC,9,struct inca_port_ioctl_parm)
#define INCA_PORT_GS_DISPDATA   _IOWR(INCA_PORT_IOC_MAGIC,10,struct inca_port_ioctl_parm)
#define INCA_PORT_GS_MDISPDATA  _IOWR(INCA_PORT_IOC_MAGIC,11,struct inca_port_ioctl_parm)
#define INCA_PORT_GS_MDISPDATA_2B3P  _IOWR(INCA_PORT_IOC_MAGIC,12,struct inca_port_ioctl_parm)
#define INCA_PORT_GS_DISPBYTES  _IOWR(INCA_PORT_IOC_MAGIC,13,struct inca_port_ioctl_parm)

The definitions for the icotl macros are located at linux-2.4.31/include/asm-mips/ioctl.h:

#define _IOC(dir,type,nr,size) \
	(((dir)  << _IOC_DIRSHIFT) | \
	 ((type) << _IOC_TYPESHIFT) | \
	 ((nr)   << _IOC_NRSHIFT) | \
	 ((size) << _IOC_SIZESHIFT))

/* used to create numbers */
#define _IO(type,nr)		_IOC(_IOC_NONE,(type),(nr),0)
#define _IOR(type,nr,size)	_IOC(_IOC_READ,(type),(nr),sizeof(size))
#define _IOW(type,nr,size)	_IOC(_IOC_WRITE,(type),(nr),sizeof(size))
#define _IOWR(type,nr,size)	_IOC(_IOC_READ|_IOC_WRITE,(type),(nr),sizeof(size))

Without going into too much detail, it boils down to the following:

Bits Meaning
31-29 Direction:

001: _IOC_NONE
010: _IOC_READ
100: _IOC_WRITE
110: _IOC_READ|_IOC_WRITE

28-16 Size of parameter
15-8 Type (Magic number)
7-0 Number (function)

Thus, the ICOTL number from our function call (0x800cbf05) is defined as follows:

which is equal to

#define INCA_PORT_IOCOUTPUT	_IOW(INCA_PORT_IOC_MAGIC,5,struct inca_port_ioctl_parm)

The inca_port_ioctl_parm structure was defined as follows:

#define GS_DISP_DATA_SIZE 1024
struct inca_port_ioctl_parm {
	int port;
	int pin;
	int value;
#ifdef DISP_GS_240x128
        unsigned char disp_bytes[GS_DISP_DATA_SIZE];
#endif
};

Since the size field in the decompiled ioctl call was 0xc = 12, this meant it only consisted of the port, pin, and value fields. Going back to the decompiled program, the ioctl call happened with a global parm variable. Before the call, the following variables were set:

parm = param_2;
DAT_1003f578 = param_3;
DAT_1003f57c = param_4;

The two unnamed variables sat right after parm in memory, which suggested it had been misidentified as a single integer variable instead of the struct consisting of three ints. After adding the inca_port_ioctl_parm structure to Ghidra and retyping parm, it looked much better:

void port_dir__Fiiii(int port_fd,int port,int pin,int value)

{
  parm.port = port;
  parm.pin = pin;
  parm.value = value;
  ioctl(port_fd,INCA_PORT_IOCOUTPUT,&parm);
  return;
}

Going back up one level, I looked at the commands that were sent through the port for initialization:

    port_write__Fiiii(display_fd,2,10,1);
    port_write__Fiiii(display_fd,2,0xb,1);
    port_write__Fiiii(display_fd,2,6,1);
    port_write__Fiiii(display_fd,2,9,1);
    port_write__Fiiii(display_fd,2,8,1);
    if (param_1 != 0) {
      port_write__Fiiii(display_fd,2,8,0);
      iVar2 = 1;
      do {
        bVar1 = iVar2 < 100000;
        iVar2 = iVar2 + 1;
      } while (bVar1);
      port_write__Fiiii(display_fd,2,8,1);
      goto LAB_00453a44;
    }

After these initialization commands were written to the port, there were some more function calls:

LAB_00453a44:
  inca_port_busy_wait();
  inca_port_write_display_cmd(0xae);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0xa0);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0xc0);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0x23);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0x81);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0x30);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0xa3);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0x2f);
  inca_port_busy_wait();
  inca_port_write_display_cmd(0xaf);
  inca_port_write_display_cmd(0xa2);
  inca_port_busy_wait();

inca_port_busy_wait does what the name suggests:

void inca_port_busy_wait(void)

{
  bool bVar1;
  int iVar2;
  
  iVar2 = 0x270e;
  do {
    bVar1 = -1 < iVar2;
    iVar2 = iVar2 + -1;
  } while (bVar1);
  iVar2 = 1;
  do {
    bVar1 = iVar2 < 100000;
    iVar2 = iVar2 + 1;
  } while (bVar1);
  return;
}

It simply runs a loop 100,000 times without actually doing anything, stalling the CPU for enough time to execute the next operation on the port. inca_port_write_display_cmd sends both commands and data to the display:

void serial_disp_write__Fiibi(int fd,int port,int data,int value)

{
  parm.pin = 0;
  parm.port = port;
  parm.value = value;
  if (data == 0) {
    ioctl(fd,0xc00cbf08,&parm);
  }
  else {
    ioctl(fd,0xc00cbf07,&parm);
  }
  return;
}

Decoding the ioctl numbers again gave the following two calls:

#define INCA_PORT_DISPCMD	_IOWR(INCA_PORT_IOC_MAGIC,7,struct inca_port_ioctl_parm)
#define INCA_PORT_DISPDATA	_IOWR(INCA_PORT_IOC_MAGIC,8,struct inca_port_ioctl_parm)

This function sends both commands and data to the display. The busy loop runs between the commands. In total, the following commands were sent to the port:

ioctl calls when setting up the display

The following values are written in sequence:

ioctl port pin value
INCA_PORT_IOCOUTPUT 2 0xA 1
INCA_PORT_IOCOUTPUT 2 0xb 1
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCOUTPUT 2 0x9 1
INCA_PORT_IOCOUTPUT 2 0x8 1
INCA_PORT_IOCOUTPUT 2 0x8 0
INCA_PORT_IOCOUTPUT 2 0x8 1
INCA_PORT_DISPCMD 2 0x1 0xe2
INCA_PORT_DISPCMD 2 0x1 0xae
INCA_PORT_DISPCMD 2 0x1 0xa0
INCA_PORT_DISPCMD 2 0x1 0xc0
INCA_PORT_DISPCMD 2 0x1 0x23
INCA_PORT_DISPCMD 2 0x1 0x81
INCA_PORT_DISPCMD 2 0x1 0x30
INCA_PORT_DISPCMD 2 0x1 0xa3
INCA_PORT_DISPCMD 2 0x1 0x2f
INCA_PORT_DISPCMD 2 0x1 0xaf
INCA_PORT_DISPCMD 2 0x1 0xa2

Note that busy waiting is necessary before the final INCA_PORT_IOCOUTPUT call and between all INCA_PORT_DISPCMD calls.

The final part of the setup function consisted of a function call to clear the display:

void clear_snom_display__3TLDv(void)

{
  inca_display_clear_row(0);
  inca_display_clear_row(1);
  inca_display_clear_row(2);
  DAT_10015fcc = 0;
  DAT_10015fc8 = 0;
  return;
}

inca_display_clear_row clears a single row on the display:

void clear_display(uint param_1)

{
  bool bVar1;
  int i;
  
  i = 0x84;
  inca_port_write_display_cmd(param_1 | 0xb0);
  inca_port_write_display_cmd(0);
  inca_port_write_display_cmd(0x10);
  do {
    inca_port_write_display_data(0);
    bVar1 = 0 < i;
    i = i + -1;
  } while (bVar1);
  return;
}

First, several INCA_PORT_DISPCMD calls are executed. The row isn’t sent directly, but is instead ORed with 0xb0, suggesting that the rows start at 0xb0. The function then sends 132 DISPDATA commands with the value 0, (presumably) meaning that each row has 132 columns. Upon booting, only the first 3 rows were written to, while the rest stayed the same. After the display was fully initialized, the program hid the arrows next to each row on the display.

void hide_arrows_snom_display__Fv(void)

{
  uint uVar1;
  
  uVar1 = 0x10;
  do {
    if (uVar1 < 0x17) {
      inca_port_write_display_cmd(0);
      inca_port_write_display_cmd(0x10);
    }
    else {
      inca_port_write_display_cmd(1);
      inca_port_write_display_cmd(0x18);
    }
    inca_port_write_display_cmd((int)(&DAT_004797f4)[uVar1 & 0xf]);
    write_display_data(0);
    uVar1 = uVar1 + 1 & 0xff;
  } while (uVar1 < 0x1e);
  return;
}

I wasn’t sure what these commands did exactly (since they differed from clearing the screen), but the array being referenced contained the following values:

Memory dump
                     DAT_004797f5                                    XREF[2]:     hide_arrows_snom_display__Fv:004
                                                                                  FUN_00454030:004540dc(R)  
004797f5 b1              undefined1 B1h
004797f6 b2              ??         B2h
004797f7 b4              ??         B4h
004797f8 b5              ??         B5h
004797f9 b6              ??         B6h
004797fa b7              ??         B7h
004797fb b0              ??         B0h
004797fc b1              ??         B1h
004797fd b2              ??         B2h
004797fe b4              ??         B4h
004797ff b5              ??         B5h
00479800 b6              ??         B6h
00479801 b7              ??         B7h
00479802 00              ??         00h
00479803 00              ??         00h
00479804 00              ??         00h
00479805 00              ??         00h
00479806 00              ??         00h
00479807 00              ??         00h
00479808 00              ??         00h
00479809 00              ??         00h
0047980a 00              ??         00h
0047980b 00              ??         00h
0047980c 00              ??         00h
0047980d 00              ??         00h
0047980e 00              ??         00h
0047980f 00              ??         00h

At this point, I knew how to initialize and clear the display, but actually drawing to it remained unclear. Looking at later function calls, the program appeared to use raster images internally:

    __11RasterImageiiRC3str(auStack_40,5,8,auStack_30);
    __as__11RasterImageRC11RasterImage(iVar5,auStack_40);
    _$_11RasterImage(auStack_40,2);

This looked like decompiled C++ code and was getting rather convoluted. I decided to take a different approach and write my own driver to experiment with.

Writing my own driver

To start, I simply ported all the functions I had knowledge of so far:

  • Setting up the port
  • Writing to the port
  • Sending commands/data to the display
  • Clearing rows

After I had implemented everything, I compiled the program using the cross-compilation toolchain and compressed it using the UPX binary provided with the GPL downloads. The program itself didn’t do much so far, but clearing the first three rows actually seemed to work! I now had to figure out how to draw to the screen, and how many rows there were in the first place. To start, I tried filling the topmost row (0) with pixels.

Going off of the screen clearing code, I tried sending 1 instead of 0 for each column:

	inca_display_write_serial(DISP_CMD, 0 | 0xb0);
	inca_display_write_serial(DISP_CMD, 0);
	inca_display_write_serial(DISP_CMD, 0x10);

	for (int i = 0; i < 132; ++i) {
		inca_display_write_serial(DISP_DATA, 0x1);
	}

This actually worked! However, the screen seemed to be filled from bottom to top, with 0 (0xb0) being the bottommost row.

Single line of pixels at the bottom

I wrapped it in a for loop to try and find out how many rows there were and got the following result:

Several lines of pixels with spacing

Since I increased the loop count incrementally, I concluded that there were 8 rows in total, with the bottom-most row being row number 0. Interestingly though, each row only contained one line of pixels each. I tried sending a few values other than 0x1, and discovered the following when sending 0xf and 0xff:

Several lines of pixels with spacing and differing thickness

I suspected that each byte sent was responsible for setting multiple pixels vertically, since sending 0xff for row 0 set 8 lines of pixels at the bottom. This would mean that the display had 64 rows, and therefore a resolution of 132x32 pixels. I wasn’t sure why setting the rows also set (only some) arrows at the same time, but at this point I hadn’t implemented the arrow hiding code.

I played around with my driver a bit more and ended up writing a small program that converted images (and videos!) into data ready to be written directly to the screen, which confirmed my theory of how the display worked.

An image of garfield smoking a pipe

Getting the backlight to work

From using the phone as it was intended, I knew that the screen also had an integrated backlight, which would definitely improve visibility and glare. So far, I hadn’t seen any reference to it in the code, so I started by searching for “light” in the symbol tree. I found the following function, which sounded rather promising:

void Light__13MatrixDisplayb(undefined4 param_1,int param_2)

{
  inca360_write_led__Fib(0,param_2);
  return;
}

inca360_write_led contains an array that maps each value between 0-16 to another value between 0-16. It takes an LED index (between 0 and 16) and the value it should be set to (0 or 1) and constructs a bit mask from it. The backlight appeared to be one of 16 controllable LEDs on the phone.

void inca360_write_led__Fib(int led,int value)

{
  ushort bitmask;
  short leds [16];
  
  leds[0] = 0xf;
  leds[1] = 0;
  leds[2] = 4;
  leds[3] = 8;
  leds[4] = 0xb;
  leds[5] = 1;
  leds[6] = 5;
  leds[7] = 2;
  leds[8] = 6;
  leds[9] = 10;
  leds[10] = 0xe;
  leds[0xb] = 3;
  leds[0xc] = 7;
  leds[0xd] = 9;
  bitmask = 1;
  if ((uint)led < 0xe) {
    bitmask = (ushort)(1 << ((int)leds[led] & 0x1fU));
  }
  if (led == 0) {
    value = value ^ 1;
  }
  if (value == 0) {
    inca360_led = bitmask | inca360_led;
  }
  else {
    inca360_led = inca360_led & ~bitmask;
  }
  write_led__FUs(inca360_led);
  return;
}

inca360_led is a global variable containing a bit array that is then used to write the physical LED states in write_led:

void write_led__FUs(uint leds)

{

  
  leds = leds & 0xffff;

  inca_port_setup();
  iVar4 = inca_port_write_bit(0);
  iVar5 = 0;
  if (iVar4 == 0) {
LAB_0042501c:
    inca_port_close();
    _$_3str(&local_38,2);
    _$_3str(auStack_48,2);
  }
  else {
    do {
      iVar4 = inca_port_write_bit(leds >> 0xf);
      if (iVar4 == 0) goto LAB_0042501c;
      leds = (leds & 0x7fff) << 1;
      iVar5 = iVar5 + 1;
    } while (iVar5 < 0x10);
    inca_port_close();
    _$_3str(&local_38,2);
    _$_3str(auStack_48,2);
  }
  return;

First, some generic setup commands are sent to the port. The function then writes a single bit (0) to the port and checks its return value. If it returns 0, the function terminates and sends some generic closing commands. Writing and reading a single bit is implemented in the inca_port_write_bit function:

int inca_port_write_bit(int value)

{
  int ret;
  
  inca_port_dir__Fiii(2,10,1);
  if (value == 0) {
    inca_port_write__Fiii(2,10,0);
  }
  else {
    inca_port_write__Fiii(2,10,1);
  }
  inca_port_write__Fiii(2,6,0);
  ret = inca_port_read_wait(0);
  if (ret != 0) {
    inca_port_dir__Fiii(2,10,0);
    inca_port_write__Fiii(2,6,1);
    ret = inca_port_read_wait(1);
    if (ret != 0) {
      return 1;
    }
  }
  inca_port_dir__Fiii(2,10,0);
  return 0;
}

It sends several ioctl commands to the port ioctl commands to the port and calls inca_port_read_wait:

int inca_port_read_wait(int value)

{
  int value_read;
  int iVar1;
  
  iVar1 = 0;
  do {
    value_read = inca_port_read__Fii(2,0xb);
    iVar1 = iVar1 + 1;
    if (value_read == value) {
      return 1;
    }
  } while (iVar1 < 3000);
  return 0;
}

This function takes a value as a parameter and then reads from the port 3000 times. If the read value matches the expected one, it returns 1, otherwise 0.

Finally, all the individual values of the leds are written, using inca_port_write_bit again.

    do {
      iVar4 = inca_port_write_bit(leds >> 0xf);
      if (iVar4 == 0) goto LAB_0042501c;
      leds = (leds & 0x7fff) << 1;
      iVar5 = iVar5 + 1;
    } while (iVar5 < 0x10);

The bits are read starting from the most significant bit and sent one each at a time.

ioctl calls when writing the LEDs

The following values are written in sequence:

Setup:

ioctl port pin value
INCA_PORT_IOCDIR 2 0x7 1
INCA_PORT_IOCDIR 2 0x6 1
INCA_PORT_IOCDIR 2 0xb 0
INCA_PORT_IOCDIR 2 0xa 0
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCOUTPUT 2 0x7 0

Write 0:

ioctl port pin value
INCA_PORT_IOCDIR 2 0xa 1
INCA_PORT_IOCOUTPUT 2 0xa 0
INCA_PORT_IOCOUTPUT 2 0x6 0
INCA_PORT_IOCINPUT 2 0xb read, =0?
INCA_PORT_IOCDIR 2 0xa 0
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCINPUT 2 0xb read, =1?

Write LED values (16x):

ioctl port pin value
INCA_PORT_IOCDIR 2 0xa 1
INCA_PORT_IOCOUTPUT 2 0xa value
INCA_PORT_IOCOUTPUT 2 0x6 0
INCA_PORT_IOCINPUT 2 0xb read, =0?
INCA_PORT_IOCDIR 2 0xa 0
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCINPUT 2 0xb read, =1?

Close:

ioctl port pin value
INCA_PORT_IOCOUTPUT 2 0xa 1
INCA_PORT_IOCOUTPUT 2 0x7 1
INCA_PORT_IOCDIR 2 0xb 1
INCA_PORT_IOCDIR 2 0xa 1

Adding LED support to the driver

Now that I had all the ioctl calls to set the LEDs, I could update my driver again. I implemented everything from the disassembled driver, but I was unsure which LED index controlled which LED. I solved this by simply bruteforcing each index, i.e. setting only a single LED to 1 at a time. Interestingly, all LEDs except for the backlight seem to be activated when writing 0 to them, while the backlight activates when writing 1. I determined the following mappings:

typedef enum {
	SNOM_LED_BACKLIGHT 	= 0,
	SNOM_LED_MESSAGE 	= 6,
	SNOM_LED_ONE 		= 13,
	SNOM_LED_TWO 		= 15,
	SNOM_LED_THREE 		= 9,
	SNOM_LED_FOUR 		= 11,
	SNOM_LED_FIVE 		= 5,
	SNOM_LED_SIX 		= 7,
	SNOM_LED_SEVEN 		= 1,
	SNOM_LED_EIGHT 		= 4,
	SNOM_LED_NINE 		= 12,
	SNOM_LED_TEN 		= 14,
	SNOM_LED_ELEVEN 	= 8, 
	SNOM_LED_TWELVE 	= 10 
} SnomLed;

The same picture of garfield again, but now with backlight

Getting input from the keyboard

All that was left now was to actually get input from the keyboard. Again, I started out by searching in the symbol tree. Keyboard seem to be read from a function inca360_read_kb:

void inca360_read_kb__FRUcRb(byte *kb_data,undefined4 *kb_event)

{
  int res;
  int i;
  int data_bit [2];
  byte tmp;
  
  data_bit[0] = 0;
  *kb_event = 0;
  *kb_data = 0;
  inca_port_setup();
  res = inca_port_write_bit(1);
  i = 0;
  if (res != 0) {
    res = inca_poll_kbd(data_bit);
    if ((res != 0) && (data_bit[0] != 0)) {
      *kb_event = 1;
      while (res = inca_poll_kbd(data_bit), res != 0) {
        tmp = *kb_data;
        *kb_data = tmp << 1;
        if (data_bit[0] != 0) {
          *kb_data = tmp << 1 | 1;
        }
        i = i + 1;
        if (7 < i) {
          port_finish();
          return;
        }
      }
    }
  }
  port_finish();
  return;
}

This function takes two parameters: one byte that contains the keycode of the key pressed, and an int that gets set based on whether a keyboard event was read or not. First, the port gets the same setup command as for the LEDs and “1” is written to the port. Then, the keyboard gets polled using inca_poll_kbd:

int inca_poll_kbd(uint *data)

{
  int ret;
  
  inca_port_write__Fiii(2,6,0);
  ret = inca_port_read_wait(0);
  if (ret != 0) {
    ret = inca_port_read__Fii(2,10);
    *data = (uint)(ret != 0);
    inca_port_write__Fiii(2,6,1);
    ret = inca_port_read_wait(1);
    if (ret != 0) {
      return 1;
    }
  }
  return 0;
}

This function writes to the port again and then reads a single bit back. This bit is passed back to the keyboard reading function. If both the return value from inca_poll_kbd and the read bit are 1, the driver registers a keypress (by setting *kbd_event = 1) and starts reading the key code, one bit at a time. The most significant bit is read first and gets shifted with each iteration. Once the key code is read, the finishing commands are sent to the port (same as for the LEDs).

ioctl calls when writing the LEDs

The following values are written in sequence:

Setup:

ioctl port pin value
INCA_PORT_IOCDIR 2 0x7 1
INCA_PORT_IOCDIR 2 0x6 1
INCA_PORT_IOCDIR 2 0xb 0
INCA_PORT_IOCDIR 2 0xa 0
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCOUTPUT 2 0x7 0

Write 0:

ioctl port pin value
INCA_PORT_IOCDIR 2 0xa 1
INCA_PORT_IOCOUTPUT 2 0xa 1
INCA_PORT_IOCOUTPUT 2 0x6 0
INCA_PORT_IOCINPUT 2 0xb read, =0?
INCA_PORT_IOCDIR 2 0xa 0
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCINPUT 2 0xb read, =1?

Poll:

ioctl port pin value
INCA_PORT_IOCOUTPUT 2 0x6 0
INCA_PORT_IOCINPUT 2 0xb read, =0?
INCA_PORT_IOCINPUT 2 0xa read, bit
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCINPUT 2 0xb read, =1?

Read bit (8x):

ioctl port pin value
INCA_PORT_IOCOUTPUT 2 0x6 0
INCA_PORT_IOCINPUT 2 0xb read, =0?
INCA_PORT_IOCINPUT 2 0xa read, bit
INCA_PORT_IOCOUTPUT 2 0x6 1
INCA_PORT_IOCINPUT 2 0xb read, =1?

Close:

ioctl port pin value
INCA_PORT_IOCOUTPUT 2 0xa 1
INCA_PORT_IOCOUTPUT 2 0x7 1
INCA_PORT_IOCDIR 2 0xb 1
INCA_PORT_IOCDIR 2 0xa 1

Adding keyboard presses to the driver

Once again, I extended my driver with what I had just learned. Sadly, there was no map available for all the possible key presses, so I had to find out the keycodes for all possible keys on my own. I did this by running a loop which polled the keyboard and, if a key press was detected, printed the key code to stdout. An event is registered both when the key is pressed and when it is released. At first I was a bit confused whether or not these were actually separate key codes, but it turned out that the type of event is stored in the most significant bit, meaning that the keycode only takes up 7 bits. The map I determined was as follows:

typedef enum {
	SNOM_KEY_HASH			= 35,
	SNOM_KEY_STAR			= 42,
	SNOM_KEY_ZERO			= 48,
	SNOM_KEY_ONE 			= 49,
	SNOM_KEY_TWO 			= 50,
	SNOM_KEY_THREE 			= 51,
	SNOM_KEY_FOUR 			= 52,
	SNOM_KEY_FIVE			= 53,
	SNOM_KEY_SIX			= 54,
	SNOM_KEY_SEVEN			= 55,
	SNOM_KEY_EIGHT			= 56,
	SNOM_KEY_NINE			= 57,
	SNOM_KEY_TRANSFER		= 65,
	SNOM_KEY_HOLD			= 66,
	SNOM_KEY_DND			= 67,
	SNOM_KEY_DIAL_ONE		= 74,
	SNOM_KEY_DIAL_TWO		= 68,
	SNOM_KEY_DIAL_THREE		= 75,
	SNOM_KEY_DIAL_FOUR		= 69,
	SNOM_KEY_DIAL_FIVE		= 76,
	SNOM_KEY_DIAL_SIX		= 70,
	SNOM_KEY_DIAL_SEVEN		= 77,
	SNOM_KEY_DIAL_EIGHT		= 71,
	SNOM_KEY_DIAL_NINE		= 78,
	SNOM_KEY_DIAL_TEN		= 72,
	SNOM_KEY_DIAL_ELEVEN 	= 78,
	SNOM_KEY_DIAL_TWELVE 	= 73,
	SNOM_KEY_QUICK_ONE 		= 97,
	SNOM_KEY_QUICK_TWO 		= 98,
	SNOM_KEY_QUICK_THREE 	= 99,
	SNOM_KEY_QUICK_FOUR 	= 100,
	SNOM_KEY_VOL_DOWN 		= 101,
	SNOM_KEY_VOL_UP			= 102,
	SNOM_KEY_CANCEL 		= 103,
	SNOM_KEY_ACCEPT 		= 104,
	SNOM_KEY_LEFT 			= 105,
	SNOM_KEY_RIGHT 			= 106,
	SNOM_KEY_UP 			= 107,
	SNOM_KEY_DOWN 			= 108,
	SNOM_KEY_RECORD			= 109,
	SNOM_KEY_RETRIEVE		= 110,
	SNOM_KEY_MUTE 			= 111,
	SNOM_KEY_SPEAKER 		= 112,
	SNOM_KEY_HEADSET		= 113,
	SNOM_KEY_REDIAL			= 114,
	SNOM_KEY_SETTINGS		= 115,
	SNOM_KEY_DIRECTORY		= 116,
	SNOM_KEY_HELP			= 117,
	SNOM_KEY_MENU			= 118,
	SNOM_KEY_SNOM			= 119,
	SNOM_KEY_CONVERENCE		= 120,
} SnomKey;

typedef enum {
	SNOM_LED_BACKLIGHT 	= 0,
	SNOM_LED_MESSAGE 	= 6,
	SNOM_LED_ONE 		= 13,
	SNOM_LED_TWO 		= 15,
	SNOM_LED_THREE 		= 9,
	SNOM_LED_FOUR 		= 11,
	SNOM_LED_FIVE 		= 5,
	SNOM_LED_SIX 		= 7,
	SNOM_LED_SEVEN 		= 1,
	SNOM_LED_EIGHT 		= 4,
	SNOM_LED_NINE 		= 12,
	SNOM_LED_TEN 		= 14,
	SNOM_LED_ELEVEN 	= 8, 
	SNOM_LED_TWELVE 	= 10 
} SnomLed;

#define SNOM_KEY_PRESSED(x) (x & 0x80)
#define SNOM_KEY_RELEASED(x) !(x & 0x80)
#define SNOM_KEY_CODE(x) (x & 0x7f)

7. Porting Doom

With the drivers complete, I could now get started with the original goal: porting Doom! In section 5, I outlined the functions needed to port doomgeneric to a new platform and will go through them one by one now.

DG_Init

Initialize your platform (create window, framebuffer, etc…).

For the Snom 360, initialization was straightforward:

void
DG_Init()
{
	snom360_setup();
	snom360_set_led(SNOM_LED_BACKLIGHT, 1);
}

I just set up the platform itself and activated the screen’s backlight. The setup details are covered in section 6, and I used my custom driver for this port (see Downloads).

DG_SleepMs

I reused the code from the reference implementation in doomgeneric_linuxvt.c:

void
DG_SleepMs(uint32_t ms)
{
	usleep(ms * 1000);
}

DG_GetTicksMs

The ticks passed since launch in milliseconds.

This function can also be taken from the reference implementation:

uint32_t
DG_GetTicksMs()
{
	struct timeval cur;
	long seconds, usec;

	gettimeofday(&cur, NULL);
	seconds = cur.tv_sec - start.tv_sec;
	usec = cur.tv_usec - start.tv_usec;

	return (seconds * 1000) + (usec / 1000);
}

where struct timeval start gets set in main:

	gettimeofday(&start, NULL);

DG_SetWindowTitle

Not required. This is for setting the window title as Doom sets this from WAD file.

I skipped this function entirely, since the phone has no concept of windows.

DG_GetKey

This was one of the more hands-on functions to implement. Based on the reference implementations, I learned that Doom uses its own keycodes which need to be mapped from the platform’s native codes. The function itself is short:

int
DG_GetKey(int* pressed, unsigned char* key)
{
	if (s_KeyQueueReadIndex == s_KeyQueueWriteIndex) {
		//key queue is empty

		return 0;
	}
	else {
		unsigned short keyCode = s_KeyQueue[s_KeyQueueReadIndex];
		s_KeyQueueReadIndex++;
		s_KeyQueueReadIndex %= KEYQUEUE_SIZE;

		*pressed = SNOM_KEY_PRESSED(keyCode);
		*key = convertToDoomKey(SNOM_KEY_CODE(keyCode));

		return 1;
	}
}

I used a simple circular buffer to provide key events to the game. The keyboard is polled every time a new frame is drawn (see DG_DrawFrame), and if an event is detected, it gets added to the queue. The platform’s key codes are translated using convertToDoomKey. When the game needs to read a key press, it provides two pointers: one for the event type, and one for the key code. Since the status was already stored in the keycodes, passing this along was straightforward.

DG_DrawFrame

Frame is ready in DG_ScreenBuffer. Copy it to your platform’s screen.

Now, this was the most important part of the whole project. Doom provides a buffer in the global DG_ScreenBuffer variable:

pixel_t* DG_ScreenBuffer = NULL;

where pixel_t is a 32-bit unsigned integer representing 8-bit RGB components. The lowest resolution I could get the game to render at was 320×200, which didn’t work well with the 132×64 screen. Instead, I decided to render at 640×400 and average four pixels at a time for downscaling. The next problem was that the LED matrix display only supports 1-bit states (on/off), while Doom provides full color. My solution was to average the RGB components of four pixels, convert them to grayscale, and use a specific cutoff value to translate 0-255 to 0/1. I had to tinker with the cutoff (contrast) quite a bit, which is why I added it as a command-line parameter.

	for (int y = 0; y < IMG_HEIGHT; y++) {
		for (int x = 0; x < IMG_WIDTH; x++) {
			// Sample from source (simple nearest neighbor)
			int src_x = (x * 640) / IMG_WIDTH;
			int src_y = (y * 400) / (IMG_HEIGHT);

			uint32_t p1 = DG_ScreenBuffer[src_y * 640 + src_x];
			uint32_t p2 = DG_ScreenBuffer[src_y * 640 + src_x + 1];
			uint32_t p3 = DG_ScreenBuffer[(src_y + 1) * 640 + src_x];
			uint32_t p4 = DG_ScreenBuffer[(src_y + 1) * 640 + src_x + 1];

			// Average the RGB components
			unsigned char r = (((p1>>16)&0xFF) + ((p2>>16)&0xFF) + ((p3>>16)&0xFF) + ((p4>>16)&0xFF)) >> 2;
			unsigned char g = (((p1>>8)&0xFF) + ((p2>>8)&0xFF) + ((p3>>8)&0xFF) + ((p4>>8)&0xFF)) >> 2;
			unsigned char b = ((p1&0xFF) + (p2&0xFF) + (p3&0xFF) + (p4&0xFF)) >> 2;

			// Convert to grayscale
			unsigned char gray = (r * 76 + g * 150 + b * 29) >> 8;

			greyscale[y * IMG_WIDTH + x] = gray > contrast;
		}
	}

First, the frame is scaled down to 132x64, and the 1-bit values are stored in the greyscale array. While this code is a bit unreadable, the underlying mechanism is rather simple. Next, The grayscale array gets packed:

	for (int i = 0; i < DISPLAY_ROWS; ++i) {
		for (int j = 0; j < DISPLAY_COLS; ++j) {
			int pb = i*DISPLAY_COLS + j;
			int pr = (DISPLAY_ROWS-1-i)*DISPLAY_COLS*8 + j;
			int o = DISPLAY_COLS;
			buf[pb] = greyscale[pr+o*0] << 7 
				| greyscale[pr+o*1] << 6
				| greyscale[pr+o*2] << 5
				| greyscale[pr+o*3] << 4
				| greyscale[pr+o*4] << 3
				| greyscale[pr+o*5] << 2
				| greyscale[pr+o*6] << 1
				| greyscale[pr+o*7] << 0;
		}
	}

Since each byte controls 8 vertical pixels, the grayscale rows needed to be packed into 1-byte bit arrays. I also had to start packing the pixels from the bottom of the image, since row 0 starts at the bottom of the screen. Finally, the buffer gets drawn to the screen:

	snom360_display_draw(buf);

This process repeats for each frame.

Building the binary

Now that all the functions have been implemented, we’ll still need to actually compile the program. I started out by copying the generic Makefile and modifying it to use my own sources, as well as the cross compilation toolchain for building. This was all rather straightforward, and I managed to compile everything without any issues. Note that I set it to compile as a static binary. While this vastly increases the file size, most of the overhead can be gotten rid of again using the UPX tool. After building and compressing the binary, I downloaded it to the phone.

                           Doom Generic 0.1
Z_Init: Init zone memory allocation daemon. 
zone memory: 0x2aba3008, 600000 allocated for zone
Using . for configuration and saves
V_Init: allocate screens.
M_LoadDefaults: Load system defaults.
saving config in .default.cfg
-iwad not specified, trying a few iwad names
Trying IWAD file:doom2.wad
Trying IWAD file:plutonia.wad
Trying IWAD file:tnt.wad
Trying IWAD file:doom.wad
Trying IWAD file:doom1.wad
Trying IWAD file:chex.wad
Trying IWAD file:hacx.wad
Trying IWAD file:freedm.wad
Trying IWAD file:freedoom2.wad
Trying IWAD file:freedoom1.wad
Game mode indeterminate.  No IWAD file was found.  Try
specifying one with the '-iwad' command line parameter.

And it runs without issue! Now we need a IWAD file to actually play the game. I started searching for a minimal, playable IWAD and found the squashware IWAD6, which provides a 1-level 660K (!) IWAD file. I loaded the file onto the phone as well, and…

W_Init: Init WADfiles.
 adding doom.wad
Z_Malloc: failed on allocation of 1882193944 bytes

it fails to load. Now, the IWAD works on my local machine, and 1882193944 seems awfully large, which is why I speculated that it might be an endianness issue. As it turns out, the cross compiler does not set the internal __BYTE_ORDER__ macro correctly, which leads to errors with the endianness handling. I added a simple workaround:

#ifdef SNOM360

#define SYS_BIG_ENDIAN

static inline unsigned short swapLE16(unsigned short val) {
	return ((val << 8) | (val >> 8));
}

static inline unsigned long swapLE32(unsigned long val) {
	return ((val << 24) | ((val << 8) & 0x00FF0000) | ((val >> 8) & 0x0000FF00) | (val >> 24));
}

#define SHORT(x)  ((signed short) swapLE16(x))
#define LONG(x)   ((signed int) swapLE32(x))

#else  // SNOM360

and compiled everything again. This time, everything worked without any issues:

                           Doom Generic 0.1
Z_Init: Init zone memory allocation daemon. 
zone memory: 0x2aba3008, 600000 allocated for zone
Using . for configuration and saves
V_Init: allocate screens.
M_LoadDefaults: Load system defaults.
saving config in .default.cfg
-iwad not specified, trying a few iwad names
Trying IWAD file:doom2.wad
Trying IWAD file:plutonia.wad
Trying IWAD file:tnt.wad
Trying IWAD file:doom.wad
W_Init: Init WADfiles.
 adding doom.wad
Using ./.savegame/ for savegames
===========================================================================
                            DOOM Shareware
===========================================================================
 Doom Generic is free software, covered by the GNU General Public
 License.  There is NO warranty; not even for MERCHANTABILITY or FITNESS
 FOR A PARTICULAR PURPOSE. You are welcome to change and distribute
 copies under certain conditions. See the source for more information.
===========================================================================
I_Init: Setting up machine state.
M_Init: Init miscellaneous info.
R_Init: Init DOOM refresh daemon - ...............
P_Init: Init Playloop state.
S_Init: Setting up sound.
D_CheckNetGame: Checking network game status.
startskill 2  deathmatch: 0  startmap: 1  startepisode: 1
player 1 of 1 (1 nodes)
Emulating the behavior of the 'Doom 1.9' executable.
HU_Init: Setting up heads up display.
ST_Init: Init status bar.
I_InitGraphics: framebuffer: x_res: 640, y_res: 400, x_virtual: 640, y_virtual: 400, bpp: 32
I_InitGraphics: framebuffer: RGBA: 8888, red_off: 16, green_off: 8, blue_off: 0, transp_off: 24
I_InitGraphics: DOOM screen size: w x h: 320 x 200
I_InitGraphics: Auto-scaling factor: 2

Now, all that was left was to play the game! On default settings, the screen was a bit hard to make out. I found a good contrast level to be around 50.

Final product

While the final port isn’t perfect (artifacts, no sound, and the text is mostly unreadable) it’s definitely a fun achievement. I’ve never done a “proper” hardware hacking project before, so this was a great opportunity to learn something new.

Maybe I’ll add sound in the future, but from what I’ve seen, that seems to be much more tedious than the other parts so far.

Downloads

Downloads will be available when I get around to cleaning up all the code.

The following downloads are available:

  • Custom driver
  • Display control tool (for showing images/videos on the phone)
  • doomgeneric patch

Should anything be missing, don’t hesitate to contact me.