Legendary Wings – Reversing a 1980s Arcade Game

I wanted to try my hand at something a bit different and reverse engineer an arcade machine from my childhood era. I knew very little about how arcade machine hardware works so it seemed like an interesting project.

This blog post will be the first in a short series intended to give some insight on how to approach a project like this. This first post mostly covers an initial look at the hardware, the tools I used and some first steps in understanding how the game works.

I decided to keep a diary for this project for each time I worked on it. One of the problems I’ve had with past projects is that when I go to write a blog post about it I know how things work, but I sometimes can’t remember how I figured that out, or all the things I got wrong along the way.

An interesting take away from keeping the diary is that the first entry is nearly a year ago. I would work on this project when I found the time, and would stop working on it when I got stuck or frustrated. The largest gap is nearly six months. Having the diary made it easy to jump back in when I found the time again.

title

Backstory

When I was a kid my Dad worked repairing and maintaining arcade machines. For a while he maintained a machine at a cafe called Chan’s in small town near where we lived. We would do a family outing every few weeks to Chan’s cafe, have lunch, play the game (on free play), and Dad would collect the coins from the machine.

Dad would change the board in the machine every few months or so to keep customers interested. He had some well known titles like Tetris and Bubble Bobble, but one of the games was a lesser known title called Legendary Wings. The game is predominately a vertical shooter, similar to Xevious or the 194x series, but had the interesting twist of also having platformer style side scrolling levels.

My brother and I would play it together trying desperately to “clock” it before we were told it was time to go home.

The Hardware

Legendary Wings was released at the tail end of the golden age of arcade games in 1986. It has two Zilog Z80 CPUs, one for the game and one for audio. The display is 240×256 pixels, with 128 colours in an upright configuration. The Arcade Museum has an entry for Legendary Wings, including flyer and cabinet art, and even the original owners manual.

Zilog Z80

Although Ghidra produces C like decompiled code it is still a good idea to learn how the underlying architecture works for this type of project. Features like interrupt handlers typically require some knowledge of the CPU, and there may be handwritten assembly components that use tricks that confuse decompilers.

I found the Z80 Heaven website to be a good reference.

One nice aspect of the Z80 architecture is that 16-bit constants are generally encoded in full and aligned on byte boundaries in the instruction encoding . For example the opcode for moving the value of the accumulator register A to address 0xc022 is:

32 22 c0: LD (0xc022), A

The address 0xc022 is encoded (as little endian) directly in the opcode. This makes it easy to search the binary for references to an address.

Tools of the Trade

The tools I use for reverse engineering are all freely available.

Ghidra

Initially I started reversing the binary with IDA Pro. For Z80 you need to either reverse in the graph, or linear disassembly modes since the Hex-Rays decompiler is not supported for old 8-bit architectures. IDA Pro also doesn’t support type annotations on Z80 code so the reversing process is fairly cumbersome.

However, in March this year the NSA released their reverse engineering tool Ghidra. Ghidra uses a common intermediate language which allows decompilation of any architecture it supports disassembly of.

Switching to Ghidra massively improved my productivity for this project. The following is small function which ensures the player doesn’t fly off the left or right hand side of the screen. In IDA it looks like this (with my slightly incorrect annotation comment at the top):

ida_func

In Ghidra it looks much better (though I still make typos):

ghidra_func

I’m able to apply type information to the decompilation resulting in automated output that is better than my hand annotated IDA output.

Mame Debugger

The other incredibly useful tool is the MAME debugger. A ROM can be launched in debug mode as follows:

mame -debug lwings

Which gives an easy to use graphical debugger interface:

mame_debug

The MAME debugger provides both breakpoints and memory watch points. The latter are very useful for determining where a piece of memory is initialised or accessed.

Memory Map

The Z80 has 16-bit addressing giving a total address space of 64K. As with many embedded systems Legendary Wings uses memory mapped IO. The input ports, dipswitches, video memory, etc are all mapped at various locations in memory. Reading the state of the joystick and buttons or drawing to the screen is no different from reading or writing a global variable in RAM. This means that you want to know what the memory map looks like before attempting to reverse engineer the code.

The memory map for Legendary Wings can be obtained from the MAME source code. If you were reversing a game (or other hardware) where you didn’t have a pre-existing memory map you would instead need to reverse engineer it from the hardware or schematics. This blog post has an example of somebody working out the memory map for an arcade machine from the schematic.

The memory map is described in mame/src/mame/drivers/lwings.cpp:

void lwings_state::lwings_map(address_map &map)
{
    map(0x0000, 0x7fff).rom();
    map(0x8000, 0xbfff).bankr("bank1");
    map(0xc000, 0xddff).ram();
    map(0xde00, 0xdfff).ram().share("spriteram");
    map(0xe000, 0xe7ff).ram().w(FUNC(lwings_state::lwings_fgvideoram_w)).share("fgvideoram");
    map(0xe800, 0xefff).ram().w(FUNC(lwings_state::lwings_bg1videoram_w)).share("bg1videoram");
    map(0xf000, 0xf3ff).ram().w(m_palette, FUNC(palette_device::write8_ext)).share("palette_ext");
    map(0xf400, 0xf7ff).ram().w(m_palette, FUNC(palette_device::write8)).share("palette");

    map(0xf808, 0xf808).portr("SERVICE");
    map(0xf809, 0xf809).portr("P1");
    map(0xf808, 0xf809).w(FUNC(lwings_state::lwings_bg1_scrollx_w));
    map(0xf80a, 0xf80a).portr("P2");
    map(0xf80b, 0xf80b).portr("DSWA");
    map(0xf80a, 0xf80b).w(FUNC(lwings_state::lwings_bg1_scrolly_w));
    map(0xf80c, 0xf80c).portr("DSWB").w(m_soundlatch, FUNC(generic_latch_8_device::write));
    map(0xf80d, 0xf80d).w("watchdog", FUNC(watchdog_timer_device::reset_w));
    map(0xf80e, 0xf80e).w(FUNC(lwings_state::lwings_bankswitch_w));
}

Note that the lwings.cpp file has descriptions for three other Capcom games, Section Z, Trojan and Avengers, which all use very similar hardware.

The first 8K of memory is the ROM, which is where most of the game code is stored. The next 4K at 0x8000 is a banked ROM. There are actually four different 4K ROMS connected to this address which can be switched between by writing to the bank switch at 0xf80e. This technique, similar to overlays, allows the game to have more data than would normally be addressable. It does however make reversing more difficult because the game can, and does, dynamically switch banks.

There are several memory ranges related to the video hardware. Arcade machines typically had several layers of graphics. The foreground and background layers are tiled, while the sprite layer allows drawing at
arbitrary locations, but is usually limited to a small number of total sprites. The palette memory stores the current set of colours. Legendary Wings uses RGB-444 colour, which allows for a maximum for 4096 colours, but can only have 128 different colours on screen at once.

The player inputs are at P1 and P2. There also two dipswitches, DSWA and DSWB. These are described in the owners manual and allow the operator to set the difficult, number of lives per credit, etc. The SERVICE port is used for the coin mechanism and the player start buttons.

Start at the Beginning

The lwings.zip file for MAME contains multiple ROM files. Again the MAME source helps with knowing which one to load into Ghidra:

ROM_LOAD( "6c_lw01.bin",  0x00000, 0x8000, CRC(b55a7f60) SHA1(e28cc540892a9ad050693900356744f8f5d05237) )

This is the code ROM for the the 0x0000-0x8000 region. I actually concatenated together the code ROM and the banked ROMs before properly understanding how the banking worked. While this is incorrect it did have the useful side effect that accesses in the banks and RAM at 0xc000 show as valid memory references in Ghidra. You could just pad the code ROM with /dev/zero to get the same effect though.

When the Z80 starts executing code at address zero. Marking this as function shows it doing a lot of initialisation work. The first two things it does are disable maskable interrupts and setup the stack pointer:

DI
LD   SP, 0xd000

Ghidra decompiles the DI (disable interrupts) instruction as the intrinsic function disableMaskableInterrupts, but doesn’t show the stack pointer initialisation in the decompilation. It does have a comment at the top of the start function though:

/* WARNING: This function may have set the stack pointer */

This is something to watch out for with both Ghidra and IDA Pro when reversing low level code. Sometimes the decompilation omits, or inaccurately represents some instructions.

The start function also resets the state of the ports clears several regions of memory. The Z80 has a nice shortcut for clearing memory:

LD   HL, 0xc000
LD   DE, 0xc001
LD   (HL=>BYTE_ram_c000), 0x0
LD   BC, 0x1fff
LDIR

This sets the source HL register to 0xc000 and the destination DE register to 0xc001 and then clears the byte at 0xc000 to zero. The counter register BC is set to the length of 0x1fff and then the LDIR instruction is executed which copies from DE to HL, decrementing BC until it is zero.

This is similar to REP MOVSB on x86 and is equivalent to:

memset(BYTE_ram_0xc000, 0, 0x2000);

Unfortunately Ghidra doesn’t decompile this as an intrinsic memset, instead producing the less obvious:

pbVar5 = &BYTE_ram_c000;
pbVar3 = &BYTE_ram_c001;
BYTE_ram_c000 = 0;
sVar1 = 0x1fff;
do {
    *pbVar3 = *pbVar5;
    pbVar3 = pbVar3 + 1;
    pbVar5 = pbVar5 + 1;
    sVar1 = sVar1 + - 1;
} while(sVar1 != 0);

The assembler code is actually shorter.

At the very bottom of the start function is this loop:

do {
    enableMaskableInterrupts();
    DAT_ram_cffe = 0x3ee;
    FUN_ram_03f1();
} while (true);

Which is likely to be the main loop for the game. Arcade games are largely input driven. When the player inserts a coin, moves the joystick or presses a button an interrupt will be triggered and the game will jump to the interrupt handler.

Input Ports

To see how the player input is handled you need to know how the controllers are connected to the CPU. Again the MAME source code saves us from having to reverse engineer the hardware:

PORT_START("SERVICE")
PORT_BIT( 0x01, IP_ACTIVE_LOW, IPT_START1 )
PORT_BIT( 0x02, IP_ACTIVE_LOW, IPT_START2 )
PORT_BIT( 0x04, IP_ACTIVE_LOW, IPT_UNKNOWN )    /* probably unused */
PORT_BIT( 0x08, IP_ACTIVE_LOW, IPT_UNKNOWN )    /* probably unused */
PORT_BIT( 0x10, IP_ACTIVE_LOW, IPT_UNKNOWN )    /* probably unused */
PORT_BIT( 0x20, IP_ACTIVE_LOW, IPT_UNKNOWN )    /* probably unused */
PORT_BIT( 0x40, IP_ACTIVE_LOW, IPT_COIN1 )
PORT_BIT( 0x80, IP_ACTIVE_LOW, IPT_COIN2 )

PORT_START("P1")
PORT_BIT( 0x01, IP_ACTIVE_LOW, IPT_JOYSTICK_RIGHT ) PORT_8WAY
PORT_BIT( 0x02, IP_ACTIVE_LOW, IPT_JOYSTICK_LEFT ) PORT_8WAY
PORT_BIT( 0x04, IP_ACTIVE_LOW, IPT_JOYSTICK_DOWN ) PORT_8WAY
PORT_BIT( 0x08, IP_ACTIVE_LOW, IPT_JOYSTICK_UP ) PORT_8WAY
PORT_BIT( 0x10, IP_ACTIVE_LOW, IPT_BUTTON1 )
PORT_BIT( 0x20, IP_ACTIVE_LOW, IPT_BUTTON2 )
PORT_BIT( 0x40, IP_ACTIVE_LOW, IPT_UNKNOWN )    /* probably unused */
PORT_BIT( 0x80, IP_ACTIVE_LOW, IPT_UNKNOWN )    /* probably unused */

The input ports are active low interrupts. This means that bits are normally high (have the value one) and when they are pulled low by moving the joystick or pressing a button an interrupt will be generated.

Input Interrupts

The Z80 has a single interrupt line for both non-maskable interrupts (NMI) and maskable interrupts. An NMI triggers a jump to location 0x66 and maskable interrupts jump to address 0x38. The input ports generate an NMI. Once address 0x66 is marked as a function Ghidra helpfully names it NMI_ISR.

The NMI interrupt handler calls several sub-functions. Looking at each of them shows that the function at 0x22b reads from addresses 0xf808, 0xf809 and 0xf80a, which are the SERVICE, P1 and P2 ports. I named this function irq_check_inputs. The function reads the player inputs as follows:

DAT_ram_c002 = ~PORT_P1;
DAT_ram_c004 = ~PORT_P2;
if (DAT_ram_c059 == '\0') {
    fun_RAM_0327(DAT_ram_c002, &DAT_ram_c008);
    ppVar2 = &DAT_ram_c010;
    fun_RAM_0327(DAT_ram_c004);
}

A few Ghidra tips here:

  • Ghidra has detected the type of DAT_ram_c059 as char which is why it is comparing against ‘\0’. Changing the type to byte will make it compare against 0 instead.
  • The type of fun_RAM_0327 starts as byte func_RAM_0327(void). Ghidra does seem to have identified that the function takes some number of arguments, but is applying them inconsistently. This can be fixed by editting the function signature and setting the use custom storage option. Looking at the assembler for the call shows the arguments are in the A and HL registers. Setting this allows the function signature to be changed to int func_RAM_0327(byte, byte *)
  • I had a problem with Ghidra renaming the registers in the disassembler view when I labeled variables in the decompilation. This made it harder for me to read the disassembly. You can disable this by going to Edit, Tool options, Listing fields, Operands fields, and unchecking the various markup options

The function at 0x327 is extracting each of the bits in the player input as individual bytes. Annotating the code gives the more readable:

g_port_p1 = ~PORT_P1;
g_port_p2 = ~PORT_P2;
if (DAT_ram_c059 == 0) {
    input_bits_to_bytes(g_port_p1, &g_player1_inputs);
    ppVar2 = &g_player2_inputs;
    input_bits_to_bytes(g_port_p2, &g_player2_inputs);
}

Note that even with the function signature fixed Ghidra is still showing an unused temporary assignment for the second argument of the second call of input_bits_to_bytes. I don’t know why this happens.

I still don’t know what address 0xc059 is used for so I’ve left it alone. Interestingly the developers have copied that values of the P1 and P2 ports to global variables and bit flipped them. The bit values are then expanded to an array of 6 bytes for the four directions, the shoot and the bomb buttons. This was probably done to simplify accessing the input state.

The input arrays for the two players are stored at 0xc008 and 0xc010.

Where is the Input Handled

The interrupt handler copies the input state to a set of global variables, but doesn’t appear to actual the input. A general rule for interrupt handlers is that they should be short so that execution can quickly return where it came from.

I started by looking for references to the player 1 shoot button. The input array for player 1 is stored at 0xc008, with the shoot button at 0xc00c. Searching references for an address doesn’t seem to work well for me in Ghidra, whereas IDA Pro handles it fine. Luckily the Z80 encoding does make it easy to hex search for addresses.

This leads to a function which Ghidra decompiles as:

void FUN_ram_1c98(undefined param_1, short param_2)
{
    ...
    bVar1 = FUN_ram_1cbd();
    if ((bVar1 & 7) != 1) {
        return;
    }
    ...
}

The reference to 0xc00c doesn’t appear in the decompilation, so it’s necessary to look at the disassembly, which starts with:

LD   HL, 0xc00c
CALL FUN_ram_1cbd

It’s worth also looking at the disassembly of the function at 0x1cbd:

LD   D, 0x0
LD   E, (IX + 0x15)
SLA  E
SLA  E
SLA  E
ADD  HL, DE
LD   A, (HL)
RET

This shows that the IX register is being used an argument to both this function and the caller. We can apply type and label information and get some reasonable decompilation:

byte FUN_ram_1cbd(byte *button, byte *param_2)
{
    return button[(ushort)(byte)(param_2[0x15] << 3)];
}

Note that Ghidra is a bit cast operator happy at times.

The trick here is that button[0 << 3] will return if the player one’s shoot button is pressed and button[1 << 3] will return if player two’s is pressed. The IX register therefore appears to be holding a pointer to a player structure where the member at 0x15 is the player number (0 for player one, 1 for player 2). Ghidra’s type system can then be used to define a structure and get:

byte button_is_pressed(byte *button, player_t *player)
{
    return button[(ushort)(byte)(player->player_num <fire_power;
    if (player->player_num == 0) {
        shot = &g_player1_shot;
        max_shots = g_max_player_shots[0];
    } else {
        shot = &g_player2_shot;
        max_shots = g_max_player_shots[1];
    }

    if (fire_power == 4) {
        do {
            if (shot->in_use == 0) {
                spawn_power_4_shot(fire_power, player, shot);
            }
            shot = shot + 1;
            max_shots = max_shots - 1;
        } while (max_shots != 0);
    } else {
        ...
    }
}

Most of this is worked out by making educated guess based on how the game works. Legendary Wings has collectables which power up the players shot. Collecting two power ups gives double shot. Collecting another two gives a three directional spread shot, and the final power gives a powerful “psycho” shot that blasts through any enemy. The fire_power field tracks this. The value 4 is the three directional shot, which has special handling in the spawn_power_4_shot function. The other shot types are all handled in the else block which has been omitted for brevity.

The hardware only allows a small number of sprites on screen at once so an array of shot object are kept, with the in_use field used to indicate if an object is used or free. The in_use field is also used for the players to indicate whether a player is currently alive.

Object Type

My data type definition for the object structure currently looks like this:

[00] in_use
[03] x_offset
[06] y_offset
[15] player_num
[18] fire_power
[1b] invincible
[1c] current_frame
[1f] sprite_set

Most of the structure is still unknown. I think some fields probably only apply to certain object types. Working out the remaining fields can either be done by finding the code that manages them, using the MAME debugger to modify the values and seeing what happens, or setting a memory watch in the MAME debugger to see when the value is read or written. For example a watch point could be added to see when the game writes to the player 1 field at 0x1a:

wp c11a,1,r

The first argument is the address to watch, the second is the length (1 byte), and the third is the type of access (r = read, w = write, rw = read or write). Memory watch points are quite powerful. The MAME debugger documentation has more information about using conditional watch points, custom output formats, etc.

Applied Cheating

This can all be double checked with the MAME debugger. I set a breakpoint on the function that I think handles the player shoot button:

bp 1c98

The game halts as expected when pressing the fire button and shows that the IX register is 0xc100 which is the player 1 structure (player 2 is at 0xc120).

You can also use the MAME debugger to cheat. The player->fire_power field is IX + 18. In the MAME memory view window set the address to 0xc118. Changing the value to 4 will give the triple shot power, and setting the value to 5 will give the full power shot:

cheat

You can make the player invincible by setting the value at IX + 0x1b (0xc11b) to 1. This is normally used by the game to make the player temporarily invincible when respawning so that they aren’t immediately killed again before having a chance to react. Note that the invincibility will be temporary. There is a function at 0x1710 which updates the player state. It handles invincibility like this:

if (player->invincible != 0) {
    player->invincible = player->invincible + 1;
    bVar4 = player->invincible;
    if (0x5f invincible = 0;
    }
}

You can kill the player by changing the in_use field at IX + 0 (0xc100) from 0xff to 0x00. Or confuse the game by changing the player_num field at IX + 15 (0xc115) to change player 1 to player 2. Or severely confuse it by claiming to be player 3.

More to Come

The next post will look at some more visual aspects of the game including how the palette is loaded and how the levels are stored and drawn.

If you enjoyed this blog post some others have also written about reverse engineering arcade machines:

3 comments

  1. Pingback: New top story on Hacker News: Legendary Wings – Reversing a 1980s Arcade Game – protipsss
  2. Mark McDougall

    As someone who has reversed a few games now using IDAPro, I am quite interested to see what you have done with Ghidra!
    Also interested to know if you have made further progress on this game? And how far you intend to take it? Have you ever considered taking the decompiled code and attempting to port it to a more capable platform, say 68K-based? I’ve done similar with hand-coded C based on my IDAPro disassembly.

    • ryiron

      Hi. Glad you enjoyed the post.

      I think the major advantage of Ghidra over IDA is simply having the decompiler work with any architecture. A lot of the time you will still want to look at the assembly, but been able to quickly grasp the high-level logic of a large function saves heaps of time in reversing.

      I do intend to write a follow up post to this. I reversed out how the level data is stored and displayed, which I think is fairly interesting.

      I’ve never tried porting a reversed game on to modern hardware. I’ve seen a few projects that have done this, and am always impressed by the amount of effort it would have taken. I generally try to start reversing with some small goal in mind, like being able to dump the levels out, or understanding how a given part of the game logic works. Often while looking at that i will discover some other interesting aspect of how the game was written.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s