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!

Space Quest View Space Quest View Space Quest View Space Quest View

Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View

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)

Space Quest View Space Quest View Space Quest View Space Quest View

Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View Space Quest View

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:

Roger Wilco Roger Wilco Roger Wilco Roger Wilco Roger Wilco Roger Wilco Roger Wilco Roger Wilco

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.