Martynas Šateika

Reverse engineering “Multi-Pulti” (1999) - Part 1

I recently went through my old CDs at home and was reminded of a video game I spent many hours playing as a kid – “Конструктор мультфильмов: Мульти-Пульти” (“Animation constructor: Multi-Pulti”). Developed by “BASI” from Ukraine in 1999 and released by the Russian publisher “МедиаХауз” (“Media House”), the game was later translated to Lithuanian and published by our own “Akelotė ir Ko”. Unfortunately I could find absolutely no information about “BASI” online, although one Russian website cataloguing old video games shows they’ve built at least 5 games between 1999 and 2003, including another constructor type game for creating simple video games.

Fueled by nostalgia, I booted up the game and instantly recognised the user interface. To give you a quick idea of how the game works, you start off with a blank canvas and can create simple movies by choosing from the supplied assets - background images, static object sprites, characters, sound effects and music. Once placed on the scene, you can drag the objects and characters with your mouse, which keeps advancing the current frame of the movie until the mouse is released. More complex movies can be created by going back to previous frames using a special slider, adding more sound effects and graphics, dragging the mouse to advance again, and so on.

“Main game screen” Main game screen, showing a canvas with a background, some text, one character in motion, and a static object (the stand on the right).

Having not done any reverse engineering in a while, I figured this was the ideal target. Although you can find the UI elements stored as BMPs in the game’s directory, most other assets are stored in these archives with the .lib extension. Not wanting to reverse something there’s an existing tool for, I searched for more information about the extension online, although it didn’t look like any of the suggestions were correct in this case. And so I began the process of reversing the structure of these files, which I’ll document in this series. Note that everything described in this first part was done without looking at the game’s assembly.

Reversing the .lib structure

The .lib files are all in a directory called BMP, which obviously contains more than just bitmap files. There are eight in total:

The file names offered some clues about what might be in them. I started by opening them up one by one in HxD, trying to look for patterns. Most files seemed to have a bunch of data followed by a kind of file listing. Moreover, it appeared like each entry in the listing had 31 bytes for the file name, followed by 8 bytes of extra data I couldn’t understand.

It all clicked after I watched the great four-part YouTube series “File Format Reverse Engineering” by CO/DE. Specifically, what helped was the idea of using Microsoft’s “ProcMon” (“Process Monitor”) tool for gaining an understanding of how the game accesses its files. This would not have helped if the game read its data files in full, stored them in memory and then processed the raw data. Fortunately, this game seemed to access these files in a pretty predictable pattern. I’ve updated the filter to include only entries where the Process Name was equal to MULTIC.EXE, the Operation was ReadFile and Path contained .lib.

ProcMon output

The pattern that emerged was:

Since the offset to the file name listing was different across different archive files, I looked at the first twelve bytes of one of the files to look for clues. And of course, bytes 9 to 12 had the offset. The reason I did not spot it at first was because I only looked at the data in big-endian byte order, yet this was in little-endian. I then went back to those 8-byte blocks after file names that I previously could not identify. In little-endian, it was immediately obvious they were the 4-byte offset and 4-byte length of each file within the archive.

With this knowledge, I started writing up a Kotlin application for unpacking these archives. The code initially had a while not end-of-file type of loop, but was later updated when I realised bytes 5-6 stored the number of files within the archive.

fun parseFile(inputStream: FileInputStream): FileDetails {
    val unknown1 = inputStream.readInt32() // TODO
    val numberOfEntries = inputStream.readInt16()
    val unknown2 = inputStream.readInt16() // TODO
    val fileNameOffset = inputStream.readInt32()

    inputStream.channel.position(fileNameOffset.toLong())
    val entryList = mutableListOf<LibEntry>()
    for (i in 0 until numberOfEntries) {
        val fileNameBytes = inputStream.readNBytes(31)
        val offset = inputStream.readInt32()
        val length = inputStream.readInt32()
        val entry = LibEntry(fileNameBytes, offset, length)
        entryList.add(entry)
    }
    return FileDetails(unknown1, unknown2, fileNameOffset, entryList)
}

readInt16 and readInt32 are two helper extension functions I wrote for more easily reading 2-byte and 4-byte values as regular Kotlin Ints.

There are still some TODOs in the code, but what I had was enough to extract most of the files.

File name encodings

While almost all the archives could be unpacked fine, some archives would yield files with invalid characters in their names, while others wouldn’t extract at all as the characters cannot be used in Windows paths. In my first attempt to convert the bytes into strings, I used the default encoding - UTF-8, but neither Lithuanian nor Cyrillic characters would show correctly. After testing several encodings, I found the Cyrillic file names were encoded with Windows-1251, and Lithuanian ones with Windows-1257. So as not to plaster encoding information everywhere, I made LibEntry simply hold onto the raw byte array, as can be seen in the code snippet above.

This left me with a single file (anm_s.lib) I couldn’t unpack as neither encoding worked. Here are the first few entries' “file names” in hex (the rest of the 31 bytes are all zeroes):

20 2B A8
3C F2 A7
60 2F A8
CF A7
A4 24 A8
74 29 A8

I’ve enumerated all available character sets on my machine and looked at each file name from each set, yet in the end decided they probably have some relation to the data in anm.lib, and aren’t even meant to be human readable names (as I found out later, this file contains thumbnails of characters, and there is no tooltip when you hover over a character in the name, so nothing readable to display). In the end I resorted to simply using the file’s index within the archive as the name.

Unpacking bitmaps

After the encoding issues were out of the way, I was able to unpack every .lib file. That doesn’t mean the unpacked data was actually readable, though - my LibFile class didn’t care about the actual content of the files and just blindly extracted offset/length pairs from an archive. Opening up a BMP in Windows 10’s Photos program didn’t work, and I had to inspect the files again in HxD. Every single file started with the bytes 28 00 00 00, which, as Wikipedia kindly pointed out, is the start of the DIB (Device-independent bitmap) header.

A bitmap image file loaded into memory becomes a DIB data structure – an important component of the Windows GDI API. The in-memory DIB data structure is almost the same as the BMP file format, but it does not contain the 14-byte bitmap file header and begins with the DIB header.

It made sense - if the bitmaps are only meant to be used in-game with the Windows APIs, the actual BMP header is not needed. An unpacker isn’t all that useful if you can’t actually view the data though, so I decided to write a small function that would add the 14-byte header back in.

/**
 * Represents a BMP header.
 */
private object BmpHeader {
  const val sizeBytes = 14
  private const val dibHeaderSizeBytes = 40
  private val firstTwoBytes = byteArrayOf(0x42, 0x4D) // BM

  /**
   * Creates the BMP header for [byteArray].
   *
   * Prepending [byteArray] with the returned value
   * should yield a valid BMP.
   */
  fun createFor(byteArray: ByteArray): ByteArray {
    val totalSizeInBytes = byteArray.size + sizeBytes
    val imageDataStartOffset = sizeBytes + dibHeaderSizeBytes
    return ByteBuffer.allocate(sizeBytes)
      .order(ByteOrder.LITTLE_ENDIAN)
      .put(firstTwoBytes)
      .putInt(totalSizeInBytes)
      .putShort(imageDataStartOffset.toShort())
      .array()
  }

  // ...
}

With it in place, BMPs could finally be opened. In fact, this allowed me to unpack 5 of the 8 archives. Interestingly, although anm_s.lib contained bitmap data, anm.lib did not, which led me to believe it housed the full animations.

BMP data Extracted contents of fon.lib., showing the game’s movie background selection.

Unpacking music and sound effects

As expected, the sound files weren’t immediately playable either. Almost every single file started off with the exact same 18 bytes:

01 00 01 00 22 56 00 00 44 AC 00 00 02 00 10 00 64 61

With the exception of 4 files in wav_mus.lib that had zeroes in place the final 64 61.

Searching for these online yielded some results about the RIFF WAVE format. Moreover, I was actually able to play the sound in Audacity by using the “Import Raw Data” functionality (you can find this under “File”“Import”“Raw Data…"). I could listen to the files by using the default import settings, however the sounds were playing too fast. Halving the sample rate from 44100Hz to 22050Hz fixed it.

Realising 22 56 00 00 is actually 22050 in hex, I went back and decoded the rest of the bytes using some online resources on WAV:

Bytes Meaning Value
01 00 Audio Format 1 (PCM)
01 00 NumChannels 1
22 56 00 00 SampleRate 22050
44 AC 00 00 ByteRate 44100
02 00 BlockAlign 2
10 00 BitsPerSample 16
64 61 data subchunk “da”

It is odd that the data subchunk is always cut off at “da” (or has zeroes) whereas all sources I found said it should say “data” (see screenshot below that contains the search results for 64 61 (“da”)). I also tried searching for the string “RIFF” and found exactly one match in the wav_ef.mus file. I could see it followed the 18-byte header shown above. Meaning, 1 out of 298 entries in that file contained this weird double header. The only header that actually contained the full string “data”, and it was useless!

Double header The first 18 bytes of the 75th entry in “wav_ef.mus” (selected). The extra “RIFF WAVE” header is visible right after and is part of the same entry (the entry is 662166 bytes long).

I was able to convert the sounds into playable WAV files by treating everything following the initial 18 bytes as raw audio in the end:

/**
 * Extractor of WAVE files.
 *
 * These files house the music and sound effects used in the games.
 */
object WavExtractHandler : FileExtractHandler {

  private val audioFormat = AudioFormat(22050f, 16, 1, true, false)

  private val expectedStartBytes: ByteArray = ByteBuffer.allocate(16)
    .order(ByteOrder.LITTLE_ENDIAN)
    .putShort(1)   // AudioFormat (1 = PCM)
    .putShort(1)   // NumChannels (1 = Mono)
    .putInt(22050) // SampleRate
    .putInt(44100) // ByteRate
    .putShort(2)   // BlockAlign
    .putShort(16)  // BitsPerSample
    // the next two bytes contain either "da" or zeroes
    .array()

  /**
   * Handles [byteArray] if it starts with [expectedStartBytes].
   *
   * The audio is stored raw in [audioFormat]. This converts it to WAVE.
   */
  override fun tryHandle(
    fileIndex: Int,
    fileName: String,
    byteArray: ByteArray
  ): HandledLibEntry? {

    if (!canHandle(byteArray)) return null
    // We add two as expectedStartBytes does not include the start of
    // the "data" subchunk as it varies across files and can contain
    // the characters "da" or, in rare cases, zeroes
    val headerSize = expectedStartBytes.size + 2
    val rawDataSize = byteArray.size - headerSize
    val frameLength = frameLength(rawDataSize.toLong())
    byteArray.inputStream().use {
      // Skip the header to get to the raw data
      it.skip(headerSize.toLong())
      val audioInputStream = AudioInputStream(
        it,
        audioFormat,
        frameLength
      )
      val outputStream = ByteArrayOutputStream()
      AudioSystem.write(
        audioInputStream,
        AudioFileFormat.Type.WAVE,
        outputStream
      )
      val updatedBytes = outputStream.toByteArray()
      val updatedFileName = updateFileName(fileName)
      return HandledLibEntry(updatedFileName, updatedBytes)
    }
  }

  /**
   * Compute the frame length required by [AudioInputStream].
   *
   * According to [AudioFormat]'s JavaDoc:
   *
   * > For encodings like PCM, a frame consists of the set of samples
   * > for all channels at a given point in time, and so the size of
   * > a frame (in bytes) is always equal to the size of a sample (in
   * > bytes) times the number of channels.
   *
   * As we have a single channel (mono), the size of a frame is equal
   * to the size of a sample in bytes. Divide the size of the raw data
   * byte array by that and you get the frame length.
   */
  private fun frameLength(rawDataSize: Long): Long {
    val bytesPerSample = audioFormat.sampleSizeInBits / Byte.SIZE_BITS
    return rawDataSize / bytesPerSample
  }

  private fun canHandle(byteArray: ByteArray) =
      byteArray.startsWith(expectedStartBytes)

  // ...
}

I made a mistake initially by not skipping the header and instructing Java’s APIs to treat every single byte as raw data. This produced this dull “tap” sound at the very start of each audio file. By the way, the file with the double header that I mentioned previously still produces that sound. Knowing now what it contains I went into the game and played the sound in the sound effect selection screen and could immediately recognise that short “tap” at the start. What most might reasonably think is a recording issue is in fact just badly formatted data. I’m probably going to set up a method to patch this entry later, though I guess BASI might not be accepting patch requests any more.

With sound extraction handled, I was able to unpack 7 of the 8 files, which is where I am at today.

Adding files back in

Adding files back into the archive means undoing the processing done during extraction. For BMPs this means removing the BMP header, for example. Leaving the LibFile class unaware of the type of data in it, I set up two interfaces - FileAddHandler and FileExtractHandler, so I could separate the processing required by the different file formats, and just pass one instance of each interface to LibFile for use during file extraction and addition (with default no-op handlers that pass the data through unaltered).

/**
 * Handles extracting a particular type of file from a ".lib" archive.
 */
interface FileExtractHandler {
  /**
   * Tries to handle the supplied [fileName] and [byteArray].
   *
   * If the file is not supported by this handler, returns null.
   */
  fun tryHandle(
    fileIndex: Int,
    fileName: String,
    byteArray: ByteArray
  ): HandledLibEntry?
}

/**
 * Handles adding a particular type of file to a ".lib" archive.
 */
interface FileAddHandler {
  /**
   * Tries to handle the supplied [byteArray].
   *
   * If the file is not supported by this handler, returns null.
   */
  fun tryHandle(byteArray: ByteArray): ByteArray?
}

HandledLibEntry is a simple data class with the updated file name and updated ByteArray. This way the BmpExtractHandler can return an object with a file name that has .BMP appended if missing, and the BMP header added, for example. I made fileIndex one of the parameters so I could allow using the index as the file name for the one archive whose entries' file names I could not decipher.

Here’s part of the BMP addition handler. There’s only two instances of it at the moment, one that automatically resizes the BMPs to at most 169 by 127 pixels for the thumbnails (visible in the selection window in the game) and one that resizes them to at most 610 by 477 pixels (visible when added to the canvas. The game doesn’t give you the ability to go full screen so this really is the most you see).

class BmpAddHandler private constructor(private val resizeTo: Dimension? = null) : FileAddHandler {

    override fun tryHandle(byteArray: ByteArray): ByteArray? {
        if (!BmpHeader.existsIn(byteArray)) return null
        val resized = when (resizeTo) {
            null -> byteArray
            else -> resizeBmp(byteArray, resizeTo)
        }
        return BmpHeader.removeFrom(resized)
    }

    companion object {
        val thumbResizing = BmpAddHandler(Dimension(169, 127))
        val fullScreenResizing = BmpAddHandler(Dimension(610, 477))
    }
}

Because backgrounds and static sprites can be moved around the scene during animation, allowing images to be added without any resizing would be pretty useful, though that is not implemented at the CLI level yet. Images are in fact the only type of file that can be added to archives at this point, I hope to implement and document WAV file addition soon.

Building a CLI

As .lib files are archives, I wanted to build a command-line interface that is similar to zip, so decided to use these commands:

list <sourcePath>
extract <sourcePath> -d <destinationPath>
add <sourcePath> <entryPath> [--name <fileName> -d <destinationPath>]
delete <sourcePath> <entryIndex>

It would be tedious to specify the handlers/charsets to use each time you used the CLI, so I set up a StandardFile enum that lists each archive file along with its name, default extract/add handlers, and default filename character set. Kotlin doesn’t have anything built-in for setting up CLIs, but JetBrains' own kotlinx-cli project seemed like a great fit. And it was - the expressiveness of Kotlin made building the main method with the sub-command handlers really easy. Here’s the extract handler for example:

@ExperimentalCli
fun main(args: Array<String>) {
    val parser = ArgParser("BASI Tools")
    val input by parser.argument(ArgType.String, description = "Input .lib file")

    class ExtractEntries : Subcommand("extract", "Extract entries") {
        val destination by option(ArgType.String, shortName = "d", description = "Destination directory").required()

        override fun execute() {
            val zipPath = Path.of(input)
            val destinationPath = Path.of(destination)
            val standardFile = StandardFile.findMatch(zipPath)
                ?: throw IllegalArgumentException("Non-standard file name received")
            val libFile = LibFile(zipPath, standardFile.extractHandler)
            libFile.extractAll(destinationPath, standardFile.fileNameCharset)
        }
    }

    // Other handlers...

    val listCommand = ListEntries()
    val extractCommand = ExtractEntries()
    val addCommand = AddEntry()
    val deleteCommand = DeleteEntry()

    parser.subcommands(listCommand, extractCommand, addCommand, deleteCommand)
    parser.parse(args)
}

Using Maven as the build tool, I added the Assembly Plugin to my pom.xml to get a fat JAR after compilation, and set up a tiny basitools.bat file in a directory on my PATH so I could more easily invoke the CLI. This points straight to the target directory of my project, meaning I always use the latest version right after it compiles:

@echo off
java -jar "C:\Users\Martynas\IdeaProjects\BasiTools\target\basi-tools-1.0-SNAPSHOT-jar-with-dependencies.jar" %*

With everything in place, inspecting archive files and manipulating them was a breeze.

E:\MultiPulti\bmp>basitools list fon.lib
  0         12     274072 BMFON01.BMP
  1     274084     292988 BMFON02.BMP
  2     567072     304616 BMFON03.BMP
  3     871688     292988 BMFON04.BMP
  4    1164676     292988 BMFON05.BMP
  5    1457664     285644 BMFON06.BMP
  6    1743308     292988 BMFON07.BMP
  7    2036296     424184 BMFON08.BMP
  8    2460480     292988 BMFON09.BMP
  9    2753468     292988 BMFON10.BMP

Conclusion

Here’s a short video of the CLI in action:

Although I can already accomplish a lot with it, I cannot yet call the tool done:

I don’t know exactly when part two of this series will be published, but I have enjoyed this project immensely so far. While I’m pretty proud of the fact everything described in this article was done without opening up a disassembler, I’ll most likely need one for figuring out the unknown header bytes' purpose, as well as the format of animation files. I have never built a tool like this for a video game before, and it’s been a great way to learn more about file formats and building command line interfaces.

There’s something about reversing an older game, too. Its age and simplicity made it an easier reversing target for a newbie like me, sure! But I also liked to think I was probably the only person in the world looking at this long-forgotten game’s internals in the last few years. Or ever.