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?
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.
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.
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.
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:
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:
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 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:
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.
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.
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 #
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
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
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
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:
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.
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 |
| 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:
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:
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.
To start, I simply ported all the functions I had knowledge of so far:
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.
I wrapped it in a for loop to try and find out how many rows there were and got the following result:
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:
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.
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.
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 |
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;
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).
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 |
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)
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.
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).
I reused the code from the reference implementation in doomgeneric_linuxvt.c:
void
DG_SleepMs(uint32_t ms)
{
usleep(ms * 1000);
}
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);
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.
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.
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.
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.
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 will be available when I get around to cleaning up all the code.
The following downloads are available:
Should anything be missing, don’t hesitate to contact me.