The Secret of the Dial-A-Pirate

Stock photo of people looking at a retro computer, from https://www.pexels.com/photo/businesspeople-looking-at-the-computer-monitor-8872597/

Ever wondered how the Secret of Monkey Island checks if you’ve entered the correct answer to the Dial-A-Pirate question? Is it relying on a simply look-up table that contains the correct year for each pirate head/location combination? Or is there some kind of algorithm to compute the year when a “pirate was hanged” in one place or another? Let’s find out!

Tabulating the numbers

Hands firmly on the wheel, let’s put all numbers into a table first to check for any obvious patterns. I’ve added a first column, Delta, which specifies the number of steps the front wheel has been rotated clockwise (Delta = 0 signifies the correct alignment of upper and lower parts). Overall, there are 15 different pirate head combinations on the wheel, each of which can occur in 7 different locations, so there’s a total of 15*7=105 possible answers. No clear pattern is apparent in the table at first sight, but at least it’s noteworthy that none of the values occurs twice.

Delta Antigua Barbados Jamaica Montserrat Nebraska St. Kitts Tortuga 
0 1710 1725 1613 1692 1665 1712 1604
1 1651 1630 1580 1656 1706 1542 1653
2 1679 1709 1723 1567 1506 1565 1641
3 1719 1594 1717 1674 1722 1720 1690
4 1694 1614 1684 1662 1716 1664 1682
5 1632 1563 1628 1655 1584 1566 1601
6 1668 1649 1643 1646 1551 1592 1619
7 1703 1693 1559 1671 1627 1654 1680
8 1726 1577 1573 1611 1707 1635 1621
9 1564 1678 1708 1672 1688 1639 1652
10 1615 1686 1701 1562 1699 1695 1689
11 1599 1597 1724 1721 1568 1704 1713
12 1669 1718 1667 1666 1705 1711 1697
13 1660 1658 1691 1673 1579 1609 1696
14 1687 1702 1685 1670 1585 1681 1624

It’s interesting to see that all years range between 1506-1726 (marked in bold font in the table). Given that these 221 values nicely map into an 8-bit (unsigned) integer (keeping in mind we’re talking about a game released in 1990), let’s subtract the minimum (1506) from all values, which results in the following table.

Delta Antigua Barbados Jamaica Montserrat Nebraska St. Kitts Tortuga 
0 204 219 107 186 159 206 98
1 145 124 74 150 200 36 147
2 173 203 217 61 0 59 135
3 213 88 211 168 216 214 184
4 188 108 178 156 210 158 176
5 126 57 122 149 78 60 95
6 162 143 137 140 45 86 113
7 197 187 53 165 121 148 174
8 220 71 67 105 201 129 115
9 58 172 202 166 182 133 146
10 109 180 195 56 193 189 183
11 93 91 218 215 62 198 207
12 163 212 161 160 199 205 191
13 154 152 185 167 73 103 190
14 181 196 179 164 79 175 118

I tried several ways of crunching these numbers to see if they resulted from some well-known operation (e.g., LFSR and/or modulo arithmetics), but all in vain. Ping me if you see a pattern.

Getting the crowbar out

With no success so far, we can still resort to the source code of the game to see how it’s checking if an entered value is correct. Several awesome tools are available to facilitate the analysis process of SCUMM files, and a good deal of documentation on the file formats can be found online.

Preparations

Decompiling the Dial-A-Pirate checking routine

  1. Navigate to the directory in the you have downloaded/cloned the scummpacker tool. There, call python src/scummpacker.py -g MI1VGA -v 4 -o . -u -i /your/path/to/monkey1 and watch it do its magic. You’ll end up with a bunch of XML files and subdirectories for each disk.

  2. Navigate to the DISK01/LE/LF_090/ subdirectory of your scummpacker output and run descumm -4 SC_150.dmp to get the first part of the code generation (note that I’ve removed the irrelevant pieces from the following code).

[0000] setVarRange(Local[0], 9, [6,13,3,12,7,9,2,5,8]);
[000D] Var[299] = getRandomNr(8);
[0011] Var[297] = Local[0 + Var[299]];
[0018] Var[300] = getRandomNr(8);
[001C] Var[298] = Local[0 + Var[300]];
[0023] Var[299] += 985;
[0028] Var[300] += 976;
[002D] Exprmode Var[295] = (Var[297] - Var[298]);
[0038] if (Var[295] < 0) {
[003F]   Var[295] += 15;
[0044] }
[0044] Var[296] = getRandomNr(6);
[0048] Exprmode Var[295] = (Var[295] + (Var[296] * 2));
[0057] if (Var[295] < 0) {
[005E]   Var[295] += 15;
[0063] }
[0063] if (Var[295] >= 15) {
[006A]   Var[295] -= 15;
[006F] }
  1. In the same directory, run descumm -4 SC_152.dmp to get the second part of the code generation/checking routine (again, irrelevant parts have been omitted).
[00CD] Exprmode Local[0] = ((Var[295] * 7) + Var[296]);
[00DC] Var[100] = GetStringChar(29, Local[0]);
[00E3] Exprmode Local[0] = (Var[100] + 1506);
  1. At offset 00DC of the previous file, there is a call to look up a character in string 29. This string is defined in SC_001.dmp. Go to the DISK01/LE/LF_010/ subdirectory of your scummpacker output and run descumm -4 SC_001.dmp in order to find the 105 lines of code that piece together string 29, byte by byte.
[001E] SetStringChar(29, 98, 181);
[0023] SetStringChar(29, 99, 212);
[....]

Bringing it all together

A simple translation of the code snippets above into Python looks as follows:

import random
random.seed()

String29 = [204, 152, 218, 166, 121,  60, 184, 145, 196, 161,  56, 201,  86, 176, 173,
            219, 185, 215, 182, 148,  95, 213, 124, 179, 160, 193, 129, 113, 188, 203,
            107, 167,  62, 133, 174, 126,  88,  74, 164, 199, 189, 115, 162, 108, 217,
            186,  73, 198, 146, 197,  57, 211, 150,  79, 205, 183, 220, 143, 178,  61,
            159, 103, 207,  58, 187, 122, 168, 200, 175, 191, 109,  71, 137, 156,   0,
            206, 190,  93, 172,  53, 149, 216,  36, 118, 163, 180,  67, 140, 210,  59,
             98, 154,  91, 202, 165,  78, 214, 147, 181, 212, 195, 105,  45, 158, 135]

offsets = [6, 13, 3, 12, 7, 9, 2, 5, 8] # pirates on the code wheel
                                        # indexed clockwise from 0 = Guybrush

num_u = random.randint(0, 8) # upper head part
num_l = random.randint(0, 8) # lower head part
lctn  = random.randint(0, 6) # location, indexed from 0 (Antigua) to 6 (Tortuga)

upper = offsets[num_u]
lower = offsets[num_l]

delta = upper - lower
if delta < 0:
	delta += 15

delta = delta + 2 * lctn
if delta < 0:
	delta += 15
if delta >= 15:
	delta -= 15

correctcode = 1506 + String29[7 * delta + lctn]
#in one line: 1506 + String29[(7 * delta on codewheel + 15 * lctn) % 105]

upperGFX = num_u + 985  # object ID of the graphics element, see below
lowerGFX = num_l + 976  # object ID of the graphics element, see below

There’s an interesting piece of information here: The upper and lower face parts are randomly selected from just nine elements. In fact, the game only ships with nine graphics for upper and lower faces each (graphics resources 976-984 for lower, 985-993 for upper parts). Nice feint.

Pirate faces that can actually occur during the Secret of Monkey Island software piracy check

These nine line up with the indices in the offsets array if you enumerate the pirate faces on the wheel, starting with Guybrush’s head at index 0 and going forward in a clockwise direction (i.e., Elaine’s head at offset 3, the skull at offset 12). Codes 0 (Guybrush), 1 (shaggy hair), 4 (two eye patches), 10 (wrinkled forehead), 11 (biting on dagger), 14 (one eye patch) are not used (at least, in the VGA version I have, string 28 reading The Secret of Monkey Island (VGA) V1.1 14.Feb.91.), and the corresponding graphics are not even present.

The secret of the Dial-A-Pirate

So, to answer the original question: The correctness of a user entry is checked by means of a look-up table stored in string 29. The offset within that table is determined based on a the Delta between upper and lower fact part (see tables above) and the location.

How the contents of string 29 were generated exactly remains a secret to be solved (by someone else), though. But interpreting its 105 entries as if they represented a 15x7 pixel image, here’s a visualization of string 29 using the color palette file DISK01/LE/LF_010/RO/PA.dmp.

Visualization of the string 29 code array

Bonus material: Accessing the GFX files

While scummpacker and descumm are perfectly suited to extract all elements from the container files, I could not find any working tool to extract (and show and/or save) the graphical elements from the game (to check which pirate heads are actually contained in the game files).

So here’s a solution (heavily inspired by the SCUMM Revisited subpage on Thirst is Nothing, Image is Everything) to fill that gap. After installing the required dependencies numpy, bitstream, and pillow, simply run it by passing a (scummpacker-generated) OI.dmp file as its argument and wait for the resulting graphics element to be shown on screen.

#!/usr/bin/env python3

# Visualization tool for SCUMM objects
# Converts scummpacker GFX resources to PNG
# (c) 2022, toeeks.eu

import argparse
import struct
import sys, os
import numpy as np
from bitstream import BitStream
from PIL import Image

parser = argparse.ArgumentParser()
parser.add_argument('OIfile', type=argparse.FileType('rb'), help="Location of the OI.dmp file to plot")
parser.add_argument('-q', '--quiet', action='store_true', help="Do not show picture in a preview window")
parser.add_argument('-w', '--write', action='store_true', help="Write output picture to file in local directory")
parser.add_argument('-v', '--verbose', action='store_true', help="Print more processing information")
parser.add_argument('-p', '--palette', type=argparse.FileType('rb'), help="Location of palette file (PA.dmp) to use")

###################################################################

def init_oc_file(filename):

	if not os.path.exists(filename):
		print("An OC file is required in the same directory as the OI file! Terminating...")
		sys.exit(-1)
	
	with open(filename, "rb") as f:
	
		# 0-3 file length
		data = f.read(4)
		size = struct.unpack_from("<L", data)[0]
		if size != os.path.getsize(filename):
			print("Incorrect OC file size (is {}, but should be {}). Terminating...".format(os.path.getsize(filename), size))
			sys.exit(-1)

		# 4-5 block type
		data = f.read(2)
		filetype = data.decode()
		if filetype != "OC":
			print("Incorrect block type. OC expected. Terminating...")
			sys.exit(-1)

		# 6-7 object ID
		byte = f.read(2)
		oid = struct.unpack_from("<H", byte)[0]
		# print("Object ID:", oid)

		# 8 ignore
		byte = f.read(1)

		# 9 X Position / 8 byte (ignore for now)
		byte = f.read(1)
		xp = struct.unpack_from("B", byte)[0]
		xpos = 8 * xp

		# 10 Y Position / 8 byte (ignore for now)
		byte = f.read(1)
		yp = struct.unpack_from("B", byte)[0]
		ypos = 8 * yp

		# 11 width / 8 byte
		byte = f.read(1)
		wdth = struct.unpack_from("B", byte)[0]
		width = 8 * wdth

		# 12-16 ignore
		byte = f.read(5)

		# 17 height / 8 byte
		byte = f.read(1)
		hght = struct.unpack_from("B", byte)[0]
		height = hght & 0xF8

		print("Object ID {}, dimensions {}x{} pixels, from {}".format(oid, width, height, filename))
		
	return (oid, width, height)

#######################################################################

def init_oi_header(f, oid, width):

	# 0-3 file length
	data = f.read(4)
	size = struct.unpack_from("<L", data)[0]
	if size != os.path.getsize(f.name):
		print("Incorrect OI file size (is {}, but should be {}). Terminating...".format(os.path.getsize(f.name), size))
		sys.exit(-1)
	if size < 16:
		print("OI file is missing actual image data. Terminating...")
		sys.exit(-1)
			
	# 4-5 block type
	data = f.read(2)
	filetype = data.decode()
	if filetype != "OI":
		print("Incorrect block type. OI expected. Terminating...")
		sys.exit(-1)

	# 6-7 object ID
	byte = f.read(2)
	oid2 = struct.unpack_from("<H", byte)[0]
	if oid != oid2:
		print("Mismatch between object IDs ({} vs. {}). Terminating...".format(oid2, oid))
		sys.exit(-1)
	
	# 8-11 unknown
	byte = f.read(4)
	
	# 12-x strip_len
	strips = []
	for i in range(int(width / 8)): # all strips are 8 pixels wide
		byte = f.read(4)
		strips.append(struct.unpack_from("<L", byte)[0] + 8) # header offset: 8 bytes
	strips.append(size) # end of file = end of last strip
	strip_lens = [j-i for i, j in zip(strips[:-1], strips[1:])]

	return strip_lens

########################################################################

def init_palette(f):
	rgb = []

	# 0-3 file length
	data = f.read(4)
	size = struct.unpack_from("<L", data)[0]
	
	# 4-5 block type
	data = f.read(2)
	filetype = data.decode()
	if filetype != "PA":
		print("Incorrect block type. PA expected. Terminating...")
		sys.exit(-1)	
		
	# 6-7 number of entries
	data = f.read(2)
	values = struct.unpack_from("<H", data)[0]
		
	# populate list
	for i in range(values):
		rgb.append(struct.unpack_from("B", f.read(1))[0])

	# insert dummy values (should never happen)
	if size < 776:
		print("WARNING: Incomplete palette file! Filling it up with zeros...")
		rgb = rgb + [0]*(768-len(rgb)) # fill to req'd size (if needed)
			
	return rgb

########################################################################

# as follows, a set of helper functions to add pixels to the output
output = None
out_hght = 0
x_offset = 0
is_horizontal = None
pixelcount = 0
pixelmodulo = 0

def init_image(width, height):
	global output, out_hght
	out_hght = height
	output = np.zeros((width, height))
	if args.verbose:
		print("Preparing an output image of {}x{} pixels".format(width, height))

def init_strip(direction):
	global is_horizontal, pixelmodulo, pixelcount, out_hght
	is_horizontal = direction	
	pixelmodulo = 8 if is_horizontal else out_hght
	pixelcount = 0

def add_pixel(color):
	global output, is_horizontal, pixelmodulo, pixelcount, x_offset, out_hght
	(c1, c2) = divmod(pixelcount, pixelmodulo)
	x = x_offset + c2 if is_horizontal else x_offset + c1
	y = c1 if is_horizontal else c2
	if (not is_horizontal and c1 >= 8) or (is_horizontal and c1 >= out_hght):
		return # ignoring overflowing data
	output[(x, y)] = color
	pixelcount += 1

def end_strip():
	global pixelcount, out_hght, x_offset
	x_offset = x_offset + 8
	if args.verbose:
		print("{} of {} pixels painted".format(pixelcount, 8*out_hght))
	if pixelcount < 8 * out_hght:
		print("WARNING: Too few pixels painted!")
	return pixelcount

########################################################################

def analyze_compression_method(cmtd):
	if cmtd == 0x01:
		compression = 0
		ho_oriented = True
		transparent = False
		palettesize = 8
	elif 0x0E <= cmtd <= 0x12:
		compression = 1
		ho_oriented = False
		transparent = False
		palettesize = cmtd - 0x0A
	elif 0x18 <= cmtd <= 0x1C:
		compression = 1
		ho_oriented = True
		transparent = False
		palettesize = cmtd - 0x14
	elif 0x22 <= cmtd <= 0x26:
		compression = 1
		ho_oriented = False
		transparent = True
		palettesize = cmtd - 0x1E
	elif 0x2C <= cmtd <= 0x30:
		compression = 1
		ho_oriented = True
		transparent = True
		palettesize = cmtd - 0x28
	elif 0x40 <= cmtd <= 0x44:
		compression = 2
		ho_oriented = True
		transparent = False
		palettesize = cmtd - 0x3C
	elif 0x54 <= cmtd <= 0x58:
		compression = 2
		ho_oriented = True
		transparent = True
		palettesize = cmtd - 0x51
	elif 0x68 <= cmtd <= 0x6C:
		compression = 2
		ho_oriented = True
		transparent = True
		palettesize = cmtd - 0x64
	elif 0x7C <= cmtd <= 0x80:
		compression = 2
		ho_oriented = True
		transparent = False
		palettesize = cmtd - 0x78
	else:
		print("Invalid image compression method. Terminating...")
		sys.exit(-1)
	return (compression, ho_oriented, transparent, palettesize)

#####################################################################

def decompress1(flippedbits, palettesize, paletteindex):
	subtraction = 1
	
	while len(flippedbits) > 0:
		b = flippedbits.read(bool, 1)[0]
				
		if b is False:
			add_pixel(paletteindex)					
		else:
			b = flippedbits.read(bool, 1)[0]					
			if b is False:
				p = flippedbits.read(bool, palettesize)
				paletteindex = sum(2**i for i, v in enumerate(p) if v)
				subtraction = 1
				add_pixel(paletteindex)				
			else:
				b = flippedbits.read(bool, 1)[0]
				subtraction = -subtraction if b else subtraction
				paletteindex = paletteindex - subtraction
				add_pixel(paletteindex)

def decompress2(flippedbits, palettesize, paletteindex):
	while len(flippedbits) > 0:
		b = flippedbits.read(bool, 1)[0]
				
		if b is False:
			add_pixel(paletteindex)					
		else:
			b = flippedbits.read(bool, 1)[0]
			if b is False:
				p = flippedbits.read(bool, palettesize)
				paletteindex = sum(2**i for i, v in enumerate(p) if v)
				add_pixel(paletteindex)												
			else:
				p = flippedbits.read(bool, 3)
				action = sum(2**i for i, v in enumerate(p) if v)
				if action == 4:
					p = flippedbits.read(bool, 8)
					rle = sum(2**i for i, v in enumerate(p) if v)
					for i in range(rle):
						add_pixel(paletteindex)						
				else:
					paletteindex += 4 - action
				
########################################################################

def process_strip(f, num, l):
	if args.verbose:
		print("*** Processing strip", 1+num, "with a length of", l, "bytes ***")
		
	# 0 compression method
	data = f.read(1)
	cmtd = struct.unpack_from("B", data)[0]
	
	(compression, ho_oriented, transparent, palettesize) = analyze_compression_method(cmtd)
	if args.verbose:
		print("Compression method: {}, Orientation: {}, Transparency: {}, Palette size: {} bits".format(("Uncompressed" if compression == 0 else compression), "horizontal" if ho_oriented else "vertical", transparent, palettesize))

	# 1 first color
	data = f.read(1)
	paletteindex = struct.unpack_from("B", data)[0]	

	init_strip(ho_oriented) # init strip
	add_pixel(paletteindex) # first pixel always defined by paletteindex

	# 2-l actual data
	bitmapdata = f.read(l-2)
	
	# simple case: no compression
	if compression == 0:
		for pixel in bitmapdata:
			add_pixel(pixel)
			
	else: # decode according to http://jsg.id.au/scumm/scummrev/articles/image.html
		# flip order of bits in the data, so they can be read sequentially
		flippedbits = BitStream()
		for byteentry in bitmapdata:
			for i in range(8):
				flippedbits.write((byteentry & (1<<i)), bool)
		for i in range(9): # last pixel(s) sometimes omitted from files, so add dummies
			flippedbits.write(False, bool) 
		
		if compression == 1:	
			decompress1(flippedbits, palettesize, paletteindex)
		elif compression == 2:
			decompress2(flippedbits, palettesize, paletteindex)
			
	end_strip()

###################################################################

# get command line arguments
args = parser.parse_args()

# guess OC name from OI input file, then try to read image properties
ocfile = args.OIfile.name.replace('OI', 'OC')
oid, width, height = init_oc_file(ocfile)
if width == 0 or height == 0:
	print("Image is empty, skipping...")
	sys.exit(-1)

# process OI file
with args.OIfile as f:
	strip_lens = init_oi_header(f, oid, width)
	init_image(width, height)
	for num, l in enumerate(strip_lens):
		process_strip(f, num, l)

# load color palette
if args.palette is not None:
	rgb = init_palette(args.palette)
else: # assume PA file location based on scummpacker convention
	palfile = os.sep.join(args.OIfile.name.split(os.sep)[:-3] + ["PA.dmp"])
	with open(palfile, "rb") as f:
		rgb = init_palette(f)

# rotate and flip output array to correct orientation
output = np.fliplr(np.rot90(output, 3)).astype(np.uint8)
pi = Image.fromarray(output, mode='P') # prepare PIL image
pi.putpalette(rgb) # apply palette

if not args.quiet:
	pi.show() # show it

if args.write: # save it to PNG on request
	outfile = os.path.dirname(args.OIfile.name).split(os.sep)[-1].replace('_','') + ".png"
	pi.save(outfile)