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, 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:
- anm.lib
- anm_s.lib
- fon.lib
- fon_s.lib
- stat.lib
- stat_s.lib
- wav_ef.lib
- wav_mus.lib
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.
The pattern that emerged was:
- Read the first 12 bytes,
- Read the first 4096 bytes,
- Jump to an offset towards the end of the file and read it until the end,
- Perform a number of smaller reads across the file.
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 Int
s.
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.
- anm_s.lib - Characters, thumbnails
- fon.lib - Backgrounds, full size
- fon_s.lib - Backgrounds, thumbnails
- stat.lib - Static objects, full size
- stat_s.lib - Static objects, thumbnails

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!

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 do not yet know what 6 of the 12 bytes in the .lib file header mean (see first code snippet in this post). If I was certain that the type of file was specified in the header, then I wouldn’t need to rely on the archive file’s name at all in the CLI, and instead determine which charset / handlers to use right after inspecting those bytes.
- While I can extract bitmaps and sound effects, I have not yet fully reversed the animation file format (hence the “part 1” in the title). I’m now up to the point where I can turn each frame within the animation into its own file, but that’s about it.
- Bitmaps can be added back to archives, but audio files cannot. I need to figure out how to turn valid WAVE files back to the type of audio data required by the game, probably by chopping up “data” at “da” just to make the game happy!
- Only actual bitmap files can be added to archives. One nice side effect of using an interface for handlers is they can
be composed. The
BmpAddHandler
could be wrapped in some other handler that accepts any image file and returns aByteArray
containing BMP data.
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.