Decoding and Decompressing PM2’s Background Images

Decoding and Decompressing PM2’s Background Images
Page content

So! This is it. After slacking off for a while, I think I’m ready to reverse engineer the Premier Manager 2 (PM2) image compression format for the background (.gnd) files. A year or so ago, I worked on the .vga files, so now it’s time to try decoding the .gnd files. I haven’t touched this in a while, so I’m not sure how far I got last time. Without further ado, let’s start!

Judging by the filenames, the .gnd files are background images. The easiest way to begin is to find a way to reliably display a specific image in the game, inspect the file for patterns, and then modify it byte by byte to see the effects.

I’m doing this on my Mac, but you can use whatever OS you prefer. I’m using DosBox-X as my DOS emulator.

First Steps

First, here are all the .gnd files sorted by size so I can choose the smallest one to start with:

ls -lSh *.gnd
-rw-r--r--@ 1 nunoalves  staff    34K Dec 24  1996 credits.gnd
-rw-r--r--@ 1 nunoalves  staff    30K Dec 24  1996 seas2.gnd
-rw-r--r--@ 1 nunoalves  staff    26K Dec 24  1996 seas1.gnd
-rw-r--r--@ 1 nunoalves  staff    24K Dec 24  1996 titlepic.gnd
-rw-r--r--@ 1 nunoalves  staff    20K Dec 24  1996 seas3.gnd
-rw-r--r--@ 1 nunoalves  staff    18K Dec 24  1996 mainoff2.gnd
-rw-r--r--@ 1 nunoalves  staff    18K Dec 24  1996 validpi2.gnd
-rw-r--r--@ 1 nunoalves  staff    17K Dec 24  1996 result.gnd
-rw-r--r--@ 1 nunoalves  staff    16K Dec 24  1996 diskpic2.gnd
-rw-r--r--@ 1 nunoalves  staff    12K Dec 24  1996 sec2.gnd
-rw-r--r--@ 1 nunoalves  staff    12K Dec 24  1996 sponsor1.gnd
-rw-r--r--@ 1 nunoalves  staff    12K Dec 24  1996 sound.gnd
-rw-r--r--@ 1 nunoalves  staff   9.9K Dec 24  1996 report.gnd
-rw-r--r--@ 1 nunoalves  staff   9.6K Dec 24  1996 options2.gnd
-rw-r--r--@ 1 nunoalves  staff   9.3K Dec 24  1996 contract.gnd
-rw-r--r--@ 1 nunoalves  staff   6.5K Dec 24  1996 gndpane2.gnd
-rw-r--r--@ 1 nunoalves  staff   6.0K Dec 24  1996 gndside2.gnd
-rw-r--r--@ 1 nunoalves  staff   6.0K Dec 24  1996 gndgoal2.gnd
-rw-r--r--@ 1 nunoalves  staff   5.4K Dec 24  1996 padpic2.gnd
-rw-r--r--@ 1 nunoalves  staff   4.6K Dec 24  1996 tactic.gnd
-rw-r--r--@ 1 nunoalves  staff   4.2K Dec 24  1996 matchpic.gnd
-rw-r--r--@ 1 nunoalves  staff   2.7K Dec 24  1996 namespic.gnd
-rw-r--r--@ 1 nunoalves  staff   2.4K Dec 24  1996 anim.gnd
-rw-r--r--@ 1 nunoalves  staff   1.9K Dec 24  1996 monitor.gnd
-rw-r--r--@ 1 nunoalves  staff   1.6K Dec 24  1996 gndcove2.gnd
-rw-r--r--@ 1 nunoalves  staff   1.3K Dec 24  1996 fax.gnd
-rw-r--r--@ 1 nunoalves  staff   1.3K Dec 24  1996 sponsor2.gnd

So, sponsor2.gnd is the smallest, so it should be the easiest to understand and decode. To find out where it appears in-game, back it up and replace its contents with another file (for example, sec2.gnd):

mv sponsor2.gnd sponsor2.bak
cp sec2.gnd sponsor2.gnd

Weirdly, PM2 crashes whenever the sponsors window is clicked—suggesting either a format or size mismatch. I suspect a size mismatch. In any case, that crash was a godsend, because now I know sponsor2.gnd is rendered behind the sponsors icon on the main screen:

Copying sec2.gnd to sponsor2.gnd crashes PM2

As a baseline, I restore the original sponsor2.gnd and check how the sponsors menu looks:

Original sponsor2.gnd in-game

It turns out sponsor2.gnd is the cloud background. Next, let’s replace it with another file—say diskpic2.gnd:

diskpic2.gnd used as sponsor2.gnd; background changed successfully

It seems some background files crash PM2 when copied into sponsor2.gnd. Let’s see which ones crash the game and which don’t:

  • Crashes: credits.gnd, seas2.gnd, seas1.gnd, titlepic.gnd, seas3.gnd, validpi2.gnd, sec2.gnd, sponsor1.gnd, sound.gnd, report.gnd, options2.gnd, contract.gnd, gndpane2.gnd, gndside2.gnd, gndgoal2.gnd, tactic.gnd, namespic.gnd

  • OK: mainoff2.gnd, result.gnd, diskpic2.gnd, phonpic2.gnd, padpic2.gnd, matchpic.gnd, anim.gnd, monitor.gnd, gndcove2.gnd, fax.gnd

It looks like certain .gnd files have display sizes! In any case, since sec2.gnd appears in the secretary window, let’s apply the same trick and see if the rest of the files can be decoded when their contents are copied into sec2.gnd.

  • OK: seas2.gnd, seas1.gnd, seas3.gnd, validpi2.gnd, sponsor1.gnd , sound.gnd, report.gnd, options2.gnd, gndpane2.gnd, gndside2.gnd, gndgoal2.gnd, tactic.gnd, namespic.gnd
  • OK (…But Odd Colors): credits.gnd, titlepic.gnd, contract.gnd

Baseline sec2.gnd

seas2.gnd used as sec2.gnd; background changed successfully

I strongly suspect the odd colors are caused by decoding with a different palette. This is promising, because there are two palette files in the game assets. All good!

Entropy Analysis

Using a hex editor, I took a quick peek at the .gnd files and, unlike the .vga files, they appear to be compressed. To confirm this, I measured the files’ entropy with a tool like binwalk. Binwalk calculates Shannon entropy over fixed-size windows (1024 bytes each), giving a snapshot of how “random” each region is. An entropy of 0 bits/byte means all bytes are identical, whereas 8 bits/byte indicates perfectly random data. Higher entropy corresponds to higher information content. I like to think of it as how surprised you are by the next byte: if you see many consecutive identical bytes, each repeat is unsurprising—and thus low entropy.

The binwalk algorithm is straightforward:

  1. Read 1024 bytes (offset 0–1023).
  2. Count the occurrences of each byte (build a 256-element histogram).
  3. Divide each byte count by 1024 to calculate its frequency.
  4. Compute the entropy for this window.
  5. Move on to the next 1024 bytes (1024–2047) and repeat.

Running the following commands will log the raw entropy data and generate entropy graphs for each file type.

binwalk sponsors.vga --entropy --log sponsors2.txt
binwalk sec2.gnd     --entropy --log sec2.txt

In the images below, the Y-axis (0 – 8 bits/byte) shows the entropy for each 1024-byte block. sponsors.vga never exceeds about 3.5 bits/byte, whereas sec2.gnd sits around 6.3 bits/byte—implying a high degree of randomness and suggesting a pretty good compression mechanism.

sec2.gnd has high entropy, suggesting compression

sponsors.vga has low entropy, suggesting no compression

Displaying Palette Data

I have a hunch I’ll spend a lot of time examining the raw compressed files alongside their rendered images. The first step is to map each pixel byte (0x00–0xFF) to its corresponding RGB color. Since I’m a visual thinker, it’s most helpful to have a full color matrix at hand. Here’s a small Python script that generates exactly that matrix.

To run it locally:

python3 tools/printPalette.py ../assets/paldata.vga paldata_16.bmp --base 16

Main game palette colors, extracted from paldata.vga

First Steps

I am still unsure what the format of this compressed file is, but judging from when this game was made (~1992), I strongly suspect the file uses a custom variant of RLE or LZSS. Most likely a combo of both, so I’ll use that as my starting point.

To make my life easier, I’ll focus only on the smallest files that I can reliably read inside the game: fax.gnd, gndcove2.gnd, and sponsor2.gnd.

Using a hex editor (e.g. HexFiend), I compare the first hundred or so bytes of each file, looking for patterns.

Comparing the first bytes of each file

Normally, LZSS-compressed data begins with header metadata. The first two bytes of both gndcove2.gnd and sponsor2.gnd are identical. I’m not yet sure what they represent, but I suspect they relate to the image size, since both files display at the same dimensions in-game.

Next, I’ll analyze the sponsor2.gnd image. I also assume it renders top-to-bottom. Looking at the pixels, the initial pattern is “GREY WHITE WHITE GREY WHITE WHITE GREY WHITE WHITE.”

Zoomed-in view of the original sponsor2.gnd file.

The first bytes are 0x03 0x54 0x3A 0x3A 0x81. According to the palette matrix above, 0x3A is white and 0x81 is blue. If I change the first 0x3A to 0x35, some white pixels should turn red.

Replacing the first 0x3A with 0x35 in sponsor2.gnd results in the first white pixel (and many downstream pixels) turning red.

After a few more trials, errors, and investigations, here’s what I’ve discovered:

ByteOriginal (HEX) valuePurpose
003maybe next 3 bytes are literal
154pixel #1 (grey)
23Apixel #2 (white)
33Apixel #3 (white)
481 0Amaybe look back 3, copy 3
6C8 00maybe look back 6, copy 6
8C8 06maybe look back 12, copy 8
1003maybe next 3 bytes are literal
11EApixel #21 (blue)
1254pixel #22 (grey)
13EApixel #23 (blue)

It appears that the first byte specifies how many subsequent bytes are literal values. Bytes 1–3 are literal pixel values, followed by a sequence of back-reference bytes and then more literals. My hypothesis of a custom RLE compression start gaining traction.

Decoding the .gnd Files

After some byte-tweaking I reached four conclusions about the compression format:

  1. Every byte in the stream is an opcode (command) or its argument.
  2. The first byte of a file is always an opcode.
  3. Opcodes may have 0, 1, or 2 argument bytes.
  4. “Copy” opcodes reference previously decoded bytes (already-drawn pixels); they never look ahead.

My workflow was simple:

  1. Pick a .gnd file, spot patterns in the raw bytes vs. on-screen pixels.
  2. Guess the meaning of an opcode.
  3. Implement that guess in Python.
  4. Re-decode the image—if something looked wrong I knew either my guess or my code was off.

Below are a few annotated byte traces extracted from the various files I used to get the ball rolling…


Literal & Repeat Samples

mainoff2.gnd
43 3B 01 5F 44 3A 00
    -> 5x orange(3B) 1x green(5F) 7x white(3A)

result.gnd
61 1B 35 00
    -> row-fill: 319x red(35) (one full 320-pixel row minus the last pixel)

phonpic2.gnd
60 02 35 00
    -> 38x red(35)

Small “Copy” Sequences

padpic2.gnd
04 3B 5F 3A 35 00
    -> orange, green, white, red

04 3B 5F 3A 35 43 C2 00
    -> above + 6x brown(C2)

04 3B 5F 3A 35 43 C2 85 01 C0 00
    -> above + red + brown + light-brown(C0)

Long Repeat & Copy Mix

matchpic.gnd
63 AD 35 00
    -> about 3.5 rows of red(35)

63 AD 35 02 3A 3A 00
    -> above + 2x white(3A)

Experimenting With “Fill” Opcodes

Replacing the entire file with a single opcode trio and you get predictable spans:

40 35 00  -> 3x red
47 35 00  -> 10x red
4E 35 00  -> 17x red
5F 35 00  -> 34x red
60 1C 35 00 -> 64x red
61 1C 35 00 -> 320x red (full row)

Complete .gnd Opcode Reference

Opcode value(s)Argument bytesMnemonic*Action (pseudocode)Typical effect
00ENDstop()Terminates the stream
01–1FN literalsLIT Ncopy(src[pos+1 : pos+1+N])Copy the next 1-31 raw bytes
40–5FvvRPT m×vvm = (opcode & 0x3F) + 3 repeat(vv, m)Short run-length repeat (3-34 pixels)
60–7Fxx vvRPT n×vvn = ((opcode & 0x1F)<<8) + xx + 36 repeat(vv, n)Long repeat (≥ 36)
80–9FyyCPY2 off=dd = yy + 2 copy_back(d, 2)Copy 2 pixels from up to 257 bytes back
A0–BFyyCPY3 off=dd = yy + 3 copy_back(d, 3)Copy 3 pixels from up to 258 bytes back
C0–FFyyCPYk off=dd = offset + k copy_back(d, k) (The full pseudocode logic is too unwieldy to fit here, see python script for details)General copy-back (length 4-19, distance ≥ length, max ≈ 1042)

* Mnemonic legend

  • LIT N copy N literal bytes
  • RPT m×vv repeat byte vv m times
  • CPYk off=d copy k bytes from d positions back in the output buffer

The Decoder Script

I wrote a small Python tool, gndToBmp.py, that:

  • walks the stream opcode-by-opcode,
  • shows a live log of each operation,
  • writes an updated BMP after every opcode when --step is enabled.

Run it like this:

python3 gndToBmp.py ~/dosbox/pm2/gndcove2.gnd \
     ../assets/paldata.vga           \
     --scale 5                       \
     --step

The options mean:

  • --scale 5 – enlarge the output BMP 5×.
  • --step – pause after every opcode, write step_0001.bmp, step_0002.bmp, … alongside the log.

Bitmap after 276 decoded opcodes

Bitmap after 561 decoded opcodes

Here is the main terminal window showing the decoding for each instruction, where:

  • LIT N – copy N literal bytes that follow
  • RPTm×VV – repeat byte 0xVV m times
  • CPYk off=D – copy k bytes from D positions back in the history
    • CPY2 opcodes live in 80–9F
    • CPY3 opcodes live in A0–BF
    • CPY4–CPY19 opcodes live in C0–FF

Live trace: frame #, file offset, raw bytes, and decoded meaning

Decoded Images

Running all images through my script is simple. For example:

for inp in ~/dosbox/pm2/*.gnd; do
  base=$(basename "$inp")
  python3 gndToBmp.py "$inp" ../assets/paldata.vga --scale 5 --out "${base}.bmp"
done

Here are the results of running the decoding tool gndToBmp.py on all the .gnd images. The crashes reported above were indeed due to different image sizes (trying to print more data to the screen than the buffer allowed), not a different compression scheme.

anim.gnd

contract.gnd

credits.gnd

diskpic2.gnd

fax.gnd

gndcove2.gnd

gndgoal2.gnd

gndpane2.gnd

mainoff2.gnd

matchpic.gnd

monitor.gnd

namespic.gnd

options2.gnd

padpic2.gnd

phonpic2.gnd

report.gnd

result.gnd

seas1.gnd

seas2.gnd

seas3.gnd

sec2.gnd

sound.gnd

sponsor1.gnd

sponsor2.gnd

tactic.gnd

titlepic.gnd

validpi2.gnd

The files credits.gnd, titlepic.gnd, and contract.gnd appear to have been generated with a different palette. Let’s use the other palette file (paltitle.vga) included with the game files:

credits.gnd with correct palette

contract.gnd with correct palette

titlepic.gnd with correct palette

Final Thoughts

Figuring out a compression protocol without reverse-engineering the executable was actually a lot more fun than playing the game. The protocol itself feels tacked together. I’m a little confused why they made a custom RLE implementation with strange offsets, rather than using a canned LZSS implementation that’s been around since 1982 (or one of its later variants, like LZW). Anyway, it seems others have done similar things, so it must be okay. Maybe in one of my next posts I’ll compare this with other early-1990s compression schemes—to see what the Premier Manager 2 team were drinking :)