Reverse Engineering the Sierra Adventure Game Interpreter - Part 3
Continuing from my last post where I wrote a parser for the object/inventory portion of the Sierra AGI engine, I wrote a parser for the VIEW
file, which contains images such as animations and inventory object (background images are handled through a different mechanism). Most of my understanding on this topic came from a post by Chad Armstrong.
In order to parse the view file you first need to understand the different directory files, which contain offsets into the different VOL
files. This is all described in the community wiki. In order to parse the VIEWDIR
file (or any of the other directory files - LOGDIR
, PICDIR
, or SNDDIR
) you need to parse a list of 3-byte entries:
Each entry is of the following format:
Byte 1 Byte 2 Byte 3 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 V V V V P P P P P P P P P P P P P P P P P P P P
where V = VOL number and P = position (offset into VOL file).
I’m getting more comfortable with byte parsing and byte code convention, so I fairly quickly came up with a parser:
ExtractAgi::File.open(@file_path) do |file|
(0...(file.size / 3)).each do |index|
file.seek(index * 3, IO::SEEK_SET)
byte1 = file.read_u8
byte2 = file.read_u8
byte3 = file.read_u8
if [byte1, byte2, byte3].any? { |byte| byte != 0xFF }
volume = byte1 >> 4
offset = ((byte1 & 0x0F) << 16) + (byte2 << 8) + byte3
yield Directory.new(index: index, volume: volume, offset: offset)
else
yield Directory.new(index: index, volume: nil, offset: nil)
end
end
end
Unfortunately I didn’t have any way to test the directory, since it acts as an index into the VOL
files. I had to keep going and parse our the views themselves and only when I render an actual image could I get any sense of whether it all works. The volume
value indicates the name of the VOL
file to read - for example, SQ1 has 3 volume files: VOL.0
, VOL.1
, and VOL.2
. The offset
value indicates the offset into that file.
The view ‘resource’ itself is rather complicated and I read through the community wiki quite a few times before I could really make sense of it. The first step was to use the directory objects I parsed out of VIEWDIR
, find the correct location in the specified VOL
file, and read the header:
ExtractAgi::File.open(::File.join(options[:agi_path], "VOL.#{directory.volume}")) do |file|
file.seek(directory.offset, IO::SEEK_SET)
signature = file.read_u16be
raise "Unexpected signature in volume file: #{signature}" unless signature == 0x1234
file.read_u8 # volume number
file.read_u16le # resource size
file.read_u8 # Unknown view header byte1
file.read_u8 # Unknown view header byte2
number_of_loops = file.read_u8
file.read_u16le # Description position (if not zero)
loops = (0...number_of_loops).each_with_object({}) do |loop_index, result|
result[loop_index] = file.read_u16le + directory.offset + 5 # 5 is view header size before the loops start
end
end
The signature values were all correct - 0x1234
- which gave me confidence that I was on the right track. I still don’t quite get the loop and ‘cel’ concept, although the run length encoding felt very natural to me: if you’re rendering images one of the first optimizations you might make is to ‘compress’ repeated pixels.
loops.each do |loop_index, loop_offset|
file.seek(loop_offset, IO::SEEK_SET)
number_of_cels = file.read_u8
loop_positions = (0...number_of_cels).each_with_object({}) do |cel_index, result|
result[cel_index] = file.read_u16le + loop_offset
end
(0...number_of_cels).each do |cel_index|
file.seek(loop_positions[cel_index], IO::SEEK_SET)
cel_width = file.read_u8
cel_height = file.read_u8
cel_settings = file.read_u8
cel_mirror = cel_settings >> 4
cel_transparency = cel_settings & 0x0F
bitmap = Array.new(cel_height) { Array.new(cel_width * 2) }
row = 0
col = 0
end_of_cel = false
until end_of_cel
pixel = file.read_u8
if pixel.zero?
row += 1
col = 0
next if (end_of_cel = (row == cel_height))
end
color_index = pixel >> 4
number_of_pixels = pixel & 0x0F
raise 'color index invalid' if color_index.negative? || color_index > 15
color_index = 16 if color_index == cel_transparency
bitmap[row][col] = color_index
(number_of_pixels * 2).times do
col += 1
bitmap[row][col] = color_index
end
end
end
end
Now for the real litmus test - rendering this bitmap into an actual image. This meant I needed the actual RGBA values used by the game - there seems to be some variance between platforms but I just went with the documented values and I used the Chunky PNG gem in order to render a png.
png = ChunkyPNG::Image.new(bitmap[0].size, bitmap.size, ChunkyPNG::Color::TRANSPARENT)
(0...bitmap.size).each do |x|
(0...bitmap[0].size).each do |y|
png[y, x] = ExtractAgi::COLOR_TABLE[bitmap[x][y]] if bitmap[x][y]
end
end
png.save("loop_#{loop_index}_cel_#{cel_index}.png")
My first attempt rendered the images sideways, but I quickly fixed that - and I could see actual images from the game! Look at how tiny they are!




The colors were also completely wrong - I had used the colors verbatim from the wiki, but I’m obviously missing something since white is specified as 0x3F3F3F
instead of 0xFFFFFF
. I’m not completely sure what I’m missing here, but Chad Armstrong wrote another blog on the colors used by AGI so I just copied those values instead. (I also scaled the images larger here)
I picked those 2 loops since I immediately recognized them from the second room in the original SQ1 game. Here is our hero, Roger Wilco, in all his glory:
My view parser is incomplete since I am not properly dealing with the description associated with views (for inventory objects) and I’m not dealing with mirroring.
As before, the code is available on Github. At this point I can
- Parse the game vocabulary -
WORDS.TOK
- Parse the game objects / inventory -
OBJECT
- Parse the resource dictionary files -
VIEWDIR
,LOGICDIR
,PICTUREDIR
,SOUNDDIR
- Parse the view objects inside resource files -
VOL
I don’t know how far I’ll keep going, but I definitely want to take a shot at exporting the sound, since I can very clearly remember the sound effects and intro music.