The Secret of the Dial-A-Pirate
Posted · Last updated
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
-
Grab a copy of scummpacker by Laurence Dougal Myers. While it hasn’t been updated in a while and is only compatible with Python 2, it’s still the perfect tool to extract all resources from the (binary) game archives into separate files.
-
Install the scummvm-tools using the package manager of your choice (or download them from the ScummVM website). We only need one tool from the package, namely descumm.
Decompiling the Dial-A-Pirate checking routine
-
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. -
Navigate to the
DISK01/LE/LF_090/
subdirectory of your scummpacker output and rundescumm -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] }
- 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);
- At offset
00DC
of the previous file, there is a call to look up a character in string 29. This string is defined inSC_001.dmp
. Go to theDISK01/LE/LF_010/
subdirectory of your scummpacker output and rundescumm -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.
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
.
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)