I wasn't able to find any technical information on the web pertaining to Spinnaker Software's "Spinnaker Adventure System" or its "Spinnaker Adventure Language" (SAL), used in creating adventure games published by Spinnaker's imprints Windham Classics and Trillium/Telarium [the name changed to the latter because of a trademark dispute]. Besides the article linked above, there is some interesting historical background that can be found at filfre.net: a series of articles beginning with this one, and a piece focusing on Byron Preiss.
Because I feel nostalgic about some of these games, but at the same time find their input text parser to be extraordinarily frustrating, I wanted to update one or more of them to a choice-based format that would enable modern players to better enjoy them. As a first step along this path, I'm documenting here my discoveries from examining the binaries for these games and comparing the various games to one another and their different ports, and for what little it might be worth, sharing the Tester Tool I created in C# to assist in my analysis. I have no particular experience in reverse engineering, but hopefully this will inspire and assist the beginning of such an effort. For instance, it'd be great to eventually have these games added to Gargoyle and/or ScummVM's Glk engine, which could provide an enhanced experience compared to DOSBox.
NOTE: To analyze a game, the Tester Tool expects to find it in a subdirectory of "Resources\". It must be named using the game's abbreviation from the table below, followed by the port's abbreviation (e.g., "AMBAII" or "PMNIBM").
These are the games created with the Spinnaker Advanture System:
abbrev | game | year | imprint | Apple II | Atari ST | Commodore 64 | IBM PC/PCjr | Macintosh | MSX* |
---|---|---|---|---|---|---|---|---|---|
AMZ | Amazon | 1984 | Trillium/Telarium | AII | AST | C64 | IBM | MAC | MSX |
DGW | Dragonworld | 1984 | Trillium/Telarium | AII | C64 | IBM | MAC | MSX | |
F451 | Fahrenheit 451 | 1984 | Trillium/Telarium | AII | AST | C64 | IBM | MAC | MSX |
AMB | Nice Princes in Amber | 1985 | Telarium | AII | AST | C64 | IBM | MSX | |
PMN | Perry Mason: The Case of the Mandarin Murder | 1985 | Telarium | AII | AST | C64 | IBM | MSX | |
RDV | Rendezvous with Rama | 1984 | Trillium/Telarium | AII | C64 | IBM | MSX | ||
TRI | Treasure Island | 1985 | Windham Classics | AII | AST | C64 | IBM | MSX | |
WOZ | The Wizard of Oz | 1984 | Windham Classics | AII | C64 | IBM | MSX |
* = The MSX ports were published by Idealogic, in Spanish only. They appear to use a different engine altogether, so much of the information below does not apply.
Some observations about the files used by these games: Thankfully, game strings are ASCII-encoded (though AMB is partially tokenized).
filename | games | platforms | description |
---|---|---|---|
<abbrev> | all | all | Strings and data used globally. |
DEFAULTS.CST | AMB only | AST only | I'm guessing these are strings and data used globally. |
0 | 1 | DGW & RDV | IBM only | I'm guessing these are strings and data used globally across a specific disk. |
A | B | AMB, F451, PMN | IBM only | I'm guessing these are strings and data used globally across a specific disk. On some ports, they might be save files. |
A | B | C | all | MSX only | I'm guessing these identify the current disk. |
AMBGLOB | AMB only | AII,C64,IBM,MSX | Additional strings and data used globally for AMB. |
NEWDATA | all but TRI | AII,C64,IBM,MAC | Additional help particular to this game. |
VOLT | all | AII,C64,IBM,MAC | Identifies the current disk. |
SAVED | all | all | Saved game file. |
<abbrev>.DIB | AMB & PMN only | IBM only | Directory of locations with disk numbers ("a" or "b") for AMB & PMN on IBM. |
*.DIB | F451 & RDV only | MSX only | Graphics files for F451 and RDV on MSX. |
DIR | all but AMB,PMN | all | Directory of locations with disk numbers ("a" or "b"). |
<abbrev>.DST | AMB,AMZ,PMN,TRI | AST only | Directory of locations with disk numbers ("a" or "b"). |
OUTSIDE | AMB only | AST only | Additional directory of locations with disk nmbers ("a" or "b") for AMB on AST. |
<abbrev>.EXE | all | IBM only | The game executable. Note a few game strings are found here, though most strings here are applicable to the game engine generally. |
<abbrev>.PRG | all | AST only | The game executable. Note a few game strings are found here, though most strings here are applicable to the game engine generally. |
AVENTURA.COM | all | MSX only | The game executable. The Directory of locations and Vocabulary are embedded here. |
TRILL | all | AII & C64 only | The game executable? |
TRILLIUM | all | AII & C64 only | ??? |
*.STR | all | MSX only | Strings for some location files have been separated into a separate file. |
*.STR | AMB, AMZ, & PMN | MSX only | Some game strings have been separated into separate files on MSX |
<abbrev>.T | AMB, PMN, & WOZ | List of game functions? | |
<abbrev>.TOK | AMB only | AII,AST,C64,IBM | Token file. |
<abbrev>.V | all but DGW,RDV | all but AST | Vocabulary file. |
*.IB | *.JR | all | IBM only | Sound files in IBM PC and PCjr formats. |
*.FEN | AMB only | all | Data specific to the fencing (swordfighting) events for AMB. |
*.STR | PMN only | all | Some game strings have been separated into separate files for PMN (especially for cross-examinations?) |
*.CST | AMB,AMZ,PMN,TRI | AST only | Location files. |
GRAPHPDS | AMB & AMZ only | AII & AST only | Packed graphics files. |
MUSICPDS | AMB & AMZ only | AII & AST only | Packed sound files. |
*.PDS | all | MAC only | Packed graphics and sound files. |
*. (no extension) | all | IBM only | Mostly location orgraphics files. Some games use format <first initial abbrev> + <number> with no extension for graphics files. |
Game strings and other data is found in the appropriate location files.
The vocabulary files list all of the words the parser understands. Note that nearly all words are truncated, but the game can be played this way, e.g. "EXAM CHAL" will examine the chalice. For DGW & RDV, the vocabularies are embedded in the .EXE files.
To save disk space, AMB (only) uses a tokenizer of its 256 most common words to shrink the text strings a bit. Starting at address 0x102 of AMB.TOK is a list of words, from which can be created a dictionary with a serialized index. If a char is 0x80
or greater within any of the string lists from the Amber location files, then that represents the number of the token word--just subtract 0x80
. The Tester Tool expands strings for "Nine Princes" automatically.
The Tester Tool permits you to export all pictures to .PNG from the IBM versions of all 8 games. You can also get a preview of an individual file with ANSI block characters. Note that the Tester's list of pictures shows files with no extension that weren't found in the location dir file (other than <abbrev>,1,2,A,B,DIR,NEWDATA,SAVED,VOLT), but there may be false positives.
For the IBM versions, SAL uses 320x200 medium-resolution CGA, which supports three 4-color palettes and 2 intensity levels; these games only use low intensity and the first 2 palettes. Note that the Atari ST and Commodore 64/128 versions use the same resolution, but with 16 color support. The Apple II versions use the 280x192 resolution, with 6 "fringed" colors.
Pictures are either placed at the top in landscape orientation, fullscreen width with (typically) 40% of the screen height, or on one side in portrait orientation, with 45% of the screen width. Note that AMZ was ported to SAL from Apple II, and it uses most of the screen for its pictures (0xA0
for both height and width, or 320x160) [plus the text is in all-caps, ungh]. My initial analysis was done on the IBM PC port, and the Tester Tool is designed for that version, but I've begun the process of recognizing other ports.
The first 6 bytes are used as a header with the following layout:
address | use | description |
---|---|---|
00 | Palette | For PC CGA,00 =GRY (Green/Red/Yellow) or 01 =CMW (Cyan/Magenta/White) |
01 | Intensity/Bg | For PC CGA, 1st hex nibble is intensity (0 =low; 1 =bright), 2nd is background color (0-F) corresponding to PC color codes* |
02 | Unknown | Lots of variance. Maybe an identifier of some kind? Differs between ports. |
03 | Unknown | Small variance, i.e.00 -10 ?; the game freezes after drawing is complete when values are too large, or the drawing does not complete when values are too small. Buffer size, maybe? Same values in PC and C64. |
04 | Height | For PC and C64, typically eitherB0 (176px) = 88% height, or 50 (80px) = 40%-height |
05 | Width / 2 | For PC and C64, typically eitherA0 (160=>320px) = 100% width, or 48 (72=>144px) = 45%-width; though this field seems to be ignored |
* = For PC: 0=black, 1=dk.blue, 2=dk.green, 3=dk.cyan, 4=dk.red, 5=dk.magenta, 6=dk.yellow, 7=br.gray, 8=dk.gray, 9=br.blue, 10=br.green, 11=br.cyan, 12=br.red, 13=br.magenta, 14=br.yellow, 15=white
The rest of the file is pixel data. Though I don't have much experience with image formats, it seems a bit odd. It's similar to sixel (which I gather is odd enough), except this is "fourxel" and it's rotated 90 degrees. Four-pixel wide blocks are laid out top to bottom, with each block being from 0-15 pixels high. A set of three bytes represents two of these blocks, with the first byte's color map given by the 1st nibble (hexadecimal digit) of the second byte, and the 2nd nibble of the second byte gives the height of the color map in the third byte, i.e.:
byte1 (00-FF) | byte2 nibble1 (0-F) | byte2 nibble2 (0-F) | byte3 (00-FF) |
---|---|---|---|
color map | height for byte1 | height for byte3 | color map |
The following 3 bytes will place the next set of 2 blocks below the previous ones, until the height from the header 0x04 is met, then further pixels are moved back to the top and shifted right by 4 pixels. The width in header 0x05 appears to be ignored.
Color Maps: The color maps are base-4 bitmasks for the color of each of the 4 pixels. See the table below for an excerpt (the palette columns assume the background color is black):
hex data | color map | palette 0 | palette 1 |
---|---|---|---|
00 |
0 0 0 0 | K K K K |
K K K K |
01 |
0 0 0 1 | K K K G |
K K K C |
02 |
0 0 0 2 | K K K R |
K K K M |
03 |
0 0 0 3 | K K K Y |
K K K W |
04 |
0 0 1 0 | K K G K |
K K C K |
05 |
0 0 1 1 | K K G G |
K K C C |
... | |||
1B |
0 1 2 3 | K G R Y |
K C M W |
... | |||
6C |
1 2 3 0 | G R W K |
C M W K |
... | |||
B1 |
2 3 0 1 | R Y K G |
M W K C |
... | |||
C6 |
3 0 1 2 | Y K G R |
W K C M |
... | |||
FA |
3 3 2 2 | Y Y R R |
W W M M |
FB |
3 3 2 3 | Y Y R Y |
W W M W |
FC |
3 3 3 0 | Y Y Y K |
W W W K |
FD |
3 3 3 1 | Y Y Y G |
W W W C |
FE |
3 3 3 2 | Y Y Y R |
W W W M |
FF |
3 3 3 3 | Y Y Y Y |
W W W W |
- key: K=black, B=blue, G=green, C=cyan, R=red, M=magenta, Y=yellow, W=white
It took me an embarrassingly long time to figure out the appropriate bitwise operation to read out a base-4 bitmask, so if I might save you the trouble:
colorMap[0] = (byteArray >> 6) & 0x3;
colorMap[1] = (byteArray >> 4) & 0x3;
colorMap[2] = (byteArray >> 2) & 0x3;
colorMap[3] = byteArray & 0x3;
So, take an example 3 bytes: 0x1BF7C6
. You'll get a 4x15-pixel block above a 4x7-pixel block, with sets of 4-color stripes based on the color map. If it's palette 0
, low-intensity and a black background, you'll get the following (8x zoom for clarity):
1B | F | 7 | C6 | |
---|---|---|---|---|
colors 0123 | 15px high | 7px high | colors 3012 |
So, taking the first location of "Nine Princes in Amber" as an example:
address | 00 01 02 03 04 05 |
---|---|
data | 01 00 C9 03 50 A0 |
Looking at the header, we see:
0100
= palette 1 (KCMW), low-intensity and black background.C903
= unknown50A0
= 320x80 (fullscreen width, top 40% of screen)
offset | 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F |
---|---|
00 | -- -- -- -- -- -- FF FF FF FF FF FF FF 11 00 FF |
10 | 71 FC F0 11 01 05 11 15 54 11 51 11 13 55 FF 61 |
... |
Looking at the pixel data, we see that it's pretty boring at first: 61 pixels down of all white. Then 1 of all black, and seven more all white. Finally, a set of 1 pixel-high mixed black and white, and then some black and cyan until the bottom of the picture height, and back to the top of the screen (shifted by 4 pixels to the right) for the next block of white.
address | color data | height | address | height | color data | |
---|---|---|---|---|---|---|
06 | FF =W W W W |
F =x15 |
08 | F =15x |
FF =W W W W |
|
09 | FF =W W W W |
F =x15 |
0B | F =15x |
FF =W W W W |
|
0C | FF =W W W W |
F =x1 |
0E | 1 =1x |
00 =K K K K |
|
0F | FF =W W W W |
7 =x7 |
11 | 1 =1x |
FC =W W W K |
|
12 | F0 =W W K K |
1 =x1 |
14 | 1 =1x |
01 =K K K C |
|
15 | 05 =K K C C |
1 =x1 |
17 | 1 =1x |
15 =K C C C |
|
18 | 54 =C C C K |
1 =x1 |
1A | 1 =1x |
51 =C C K C |
|
1B | 11 =K C K C |
1 =x1 |
1D | 3 =3x |
55 =C C C C |
|
1E | FF =W W W W |
6 =x6 |
... | 1 =1x |
... |
Some of the graphic files invoke simple animations, but I haven't yet done much analysis on those.
I have no experience with the Commodore 64/128 or Atari ST, but I figured it'd be nice to have the 16-color version of the images, and potentially some better sound files. However, for some reason, the pictures and music for these 2 games on Atari ST--and on Apple II for AMB (not sure about AMZ, since I've been unable to extract its files so far)--have been packed into container files called GRAPHPDS and MUSICPDS. The Atari GRAPHPDS appears to contain files that originally had a .GST extension, and the MUSICPDS contains files that originally had a .MST extension. PDS might refer to the "Programmers Development System" which was a hardware/software system to use an IBM PC and cross-compile to other systems, including the Commodore 64 and Atari ST.
It would be nice not to have to figure out a container format as well, and it looks like the C64 versions do not have that issue. And even though the Atari had a more flexible color system than the Commodore, based on the screenshots online it doesn't look like Telarium really leveraged it very well. I've since discovered that the other games' Atari ports don't have that issue, so perhaps I can investigate those first, but because of the above and since not every game even got an ST port, for the moment I switched my focus to the C64. (I did do a quick comparison, and they appear to be very different formats.)
OK, I've just started this analysis, but here's what I've got so far.
It's clearly a different format from IBM, but the header is similar, and there appeared to be the tantalizing similarity of three byte sequences starting at address 0x65 (after the third reference to [50A0], the resolution). Twiddling bits showed me that I was sort of correct; that there was indeed something similar going on here with a pattern of blocks with colors being placed in the first and third byte, and a size in each of the 2 nibbles of the second byte. However, here it was instead doing color fills. So the colors are also split into nibbles, with each one representing one of 16 colors in the current palette:
0 | 1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|---|
color A2 | color A1 | num blocks A | num blocks B | color B1 | color B2 |
So, returning to "Nine Princes" for our example:
0x65 | 0x68 | 0x6B | 0x6E |
---|---|---|---|
F0 71 FC |
6C 11 60 |
1F 12 10 |
1F 13 0F |
After experimenting, it looks like the palette is slightly different from the default C64 palette.* So that means there's probably going to be another section of the file that assigns colors. Looking at F0
, I discover that Color F
is light gray on both this and the default palette; same for Color 0
: black; but here it looks like black is a no-op because there are no dividing lines in the top-left 4x8 pixel block, nor are there any for the next 6 blocks. And so the 7
in "num blocks A" says to use the same fill colors for seven 4x8 blocks. The next nibble, "num blocks B," is 1
, saying that the third byte's colors are only going to apply to the one block. And this block occurs on both sides of the dividing line between the wall and the floor; the wall being color F
again, and the floor being color C
, the medium gray.
* = C64 default palette: 0=black, 1=white, 2=red, 3=cyan, 4=purple, 5=green, 6=blue, 7=yellow, 8=orange, 9=brown, 10=yellow-green, 11=rosa, 12=blue-green, 13=lt.blue, 14=zyklam [purple-blue], 15=lt.green
Looking to the next three bytes, 6
is the blue used on the bed. But why is 6
listed before C
? It appears that the first byte fills from the bottom first, unlike the third byte; so perhaps what happens with the first byte in 0x65 is that the black isn't a no-op; it just fills it with black first and then gray goes on top because there's no dividing line here? Could be...
...No, it looks like it doesn't have to have a black border to be a divider, so there's something else that marks division between color boundaries.
In any case, the three-byte pattern looks like it changes again around address 0x1D6, a couple bytes before repeating the resolution (this time including the prior two bytes of the header; the ones I'm unclear on, after giving the signal 1010
). So what's next? ...Or should I return to the top of the file and see if that's where the palette is being set?
An Amiga magazine reviewed PMN (and is referenced by the Wikipedia article), but I can find no other indication that Amiga ports were created.
There are Mac ports for at least 3 of the games: AMZ, DGW, and F451. The pictures are higher resolution but black-and-white. I saw somewhere that RDV may also have been ported, but I haven't been able to find it (though perhaps it was referring to "Rama," an entirely separate game based on the same book). I've only been able to extract some of the files from AMZ so far, but in that case the graphics and sounds are packed into .pds files: ctxa1.pds, musa1.pds, pixa1.pds, pixa2.pds (and there's a file called names.pds that lists these filenames).
Then there's the Spanish-only remakes for MSX with totally redrawn art. However, it's difficult to test these; the SAL parser is hard enough to deal with when you're fluent in the language. Trying to use Google Translate as an intermediary is quite painful!
These games feature some music and sound effects that are... serviceable. The IBM ports come with two sound formats, *.IB for the IBM PC internal speaker (monophonic) and *.JR for the IBM PCjr TI SN76489 chip (which features a 3-channel square wave generator, and a 1-channel noise generator, though it seems that SAL does not use the noise channel).
I'm still working out the format. Here's what I have so far:
The first byte (at 0x00) is highly variable, maybe a buffer size? The second byte (0x01) has a very small range (00
-02
I think). The first often varies between IB and JR formats, and the second sometimes does as well.
The third (0x2) does not appear to vary between formats. It represents the timespan of the shortest beat length. The range is also small (01
-08
). I believe you simply multiply the number by 16 to get the number of milliseconds of each beat (so larger numbers equal a slower tempo).
The fourth and fifth bytes seem to always be 18 00
. Perhaps 18 is a cute way to reference IB? For monophonic files (which includes many of the *.JR files which are duplicates of the *.IB ones), the next 6 bytes are all 00
s, whereas for polyphonic files, positions 0x06 and 0x08 tend to be 00
and 00
or 00
and 01
.
The 15 bytes between positions 0x0B and 0x19 comprise a new section that specifies an array of note lengths that are used in the section below.
For the rest of the file, starting at 0x1A, we have note data. At position 0x1A, the header is always followed by 50 00 08 40 00 80
. I'm unclear what this means, except that 80
is a full stop that can be followed by a new channel. Every file ends with 80
. For polyphonic files, the sequence above may occur again following an 80
stop code, and this seems to set off note data for a new channel. Sometimes there seem to be more than 3 channels, so perhaps it loops back again or sets off another section? More to investigate...
If a byte follows 00 and is between C2 and FF, this indicates an absolute pitch value that the bytes that follow are based on. This may seem a fairly narrow range of values to represent a ~2500 Hz range, except that since only musical notes will be specified, this actually covers more than 4 octaves of a chromatic scale (and the frequency curve is exponential).
hex | note | midi # | freq (Hz) |
---|---|---|---|
C2 |
A3 | 57 | 220.00 |
... | |||
D0 |
C4 | 60 | 261.63 |
... | |||
E0 |
E5 | 76 | 659.46 |
... | |||
F0 |
G#6 | 92 | 1661.2 |
... | |||
FF |
G#7 | 104 | 3322.4 |
For note values, each byte represents one note or rest. The first nibble is the relative note pitch compared to the prior pitch, with 0 indicating the same note, 1-7 indicating the number of notes above, and 9-F indicating the number of notes below, with F=-1, E=-2, D=-3, etc. to 9=-7.
If the first nibble is 8, then it is a rest (no sound for the same duration as if it was a regular note)--except for 80
, which remember is the stop control code. A rest does not change the pitch; the pitch of the note following a rest is based on the note prior to the rest.
This is a short example from "Nine Princes" that emulates the sound of a hunting horn.
offset | 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F |
---|---|
000 | 2B 00 08 18 00 00 00 00 00 00 00 01 06 02 10 01 |
010 | 01 01 01 01 01 01 01 01 01 01 50 00 08 40 00 80 |
020 | 00 C6 01 72 81 91 71 83 91 74 80 |
The first section is the header. As mentioned above, I'm still working on deciphering this. However, I do know that the value 08
at 0x02, multiplied by 16 (i.e., 128), is the timespan in milliseconds of each beat. A beat might be thought of as an eighth note or a sixteenth note or a 32th note, so to get the tempo in beats per minute, you would multiply that value by 2 or 4 or 8 to get the length of a quarter note, then use the formula tempo = 60,000 / ms. In other words, if the beats in this file are thought of as 16th notes, then the tempo is 1/4 note=117 bpm.
The second section at 0x0B (in blue) is the 15-element array of note lengths. In this case, in decimal it is: { 1, 6, 2, 16, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }.
The following section at 0x1A (in green) is a control sequence of some kind. Also unclear on the specifics here, but it will be repeated at the start of a new channel.
The next section at 0x21 (in red) is the note sequence. As described above, it starts with an absolute pitch. C6
corresponds with C# in the fourth octave. The next byte, 01
, indicates no pitch change, for the note length in the first index of the array (in this case, one beat). 72
indicates a rise of 7 half-steps (a.k.a. semitones) (i.e., C#->D->D#->E->F->F#->G->G#) up to G#, still in the fourth octave, for the length provided in the second index of the array (six beats). The next byte, 81
, since the first nibble is 0x8 and it's not 0x80 (the stop control code), then it's a rest for the length in the first index of the array (one beat). 91
indicates a fall by 7 half-steps, returning us to C#4, for one beat. In the end, the entirety of the audio is:
C#4 x1, G#4 x6, rest x1, C#4 x1, G#4 x1, rest x2, C#4 x1, G#4 x16
It sounds like: "Da-doooo, da-do da-dooooooo"
And finally, we get a 80
stop control code.