I recently rediscovered an obscure 1997 Simon & Schuster / Marshall Media edutainment game for Windows 95 that I played as a kid: Math Invaders. Let’s reverse engineer the game a bit and see what we can find; are there any secrets, unused assets, etc?


Poking around the CD

Installing Math Invaders merely copies the EXE to C:\MathInvaders (or your chosen installation path). When run, the executable checks if you have the CD inserted (searching for a path stored in the registry during installation). So in practice, all of the resources can be found on the CD and the CD only.

📁 DIRECTX
📁 PAKS
📁 WIN.31
📁 WIN.95
📄 AMOVIE.EX_
🔧 AUTORUN.INF
📕 DSETUP.DLL
📕 DSETUP6E.DLL
📕 DSETUP6J.DLL
📕 DSETUPE.DLL
📕 DSETUPJ.DLL
⚙️ LAUNCH.EXE
📄 MATHINV.EX_
📄 README.TXT
⚙️ SETUP.EXE
⚙️ SPRINT.EXE
📄 SSPUNINS.EX_

So, we have a few directories. PAKS includes the game resources, while the others are all installer files for a bundled DirectX 4.0 and “Sprint Internet Passport 3.01” (which seems to be an AOL-like service). The remaining files are largely DLLs to support the various installers, as well as a readme for our game.

Readme Contents, for those interested.
MATH INVADERS   v1.0
(c) 1997 Simon & Schuster Interactive
__________________________________________________________________

SYSTEM REQUIREMENTS:

 * Windows 95
 * Pentium 100 with 16Mb of RAM
 * 4x CD-ROM
 * DirectX-compatible video running 256 colors or higher
 * Mouse

__________________________________________________________________

INSTALLATION:

NOTE: This game runs only under Windows 95 and requires both DirectX
and Active Movie. During installation, you will be prompted to install
both components. If you already have DirectX or ActiveX, you may be
able to bypass installation of that component.

To Install Math Invaders:

Math Invaders supports Autoplay, so if your CD-ROM drive has Autoplay
enabled, you only need to put the CD-ROM in the drive and click the Install
button on the screen that appears. Installation of both DirectX and Active
Movie is required to play Math Invaders.

If you don't have Autoplay enabled:

1. From the Start Menu, select Run...
2. Click the Browse button and located your CD-ROM drive (usually D:)
3. Double-click on the SETUP.EXE file
4. Click the OK button to bring up the Math Invaders install window.
5. Click the Install button to install Math Invaders.
6. If your system does not have DirectX or Active Movie, click Yes to
   install those components.

After installation you may be asked to reboot your system.

__________________________________________________________________

TO START MATH INVADERS:

Math Invaders supports Autoplay, so if your CD-ROM drive has Autoplay
enabled, you only need to put the CD-ROM in the drive and click the Play
button on the screen that appears.

If you don't have Autoplay enabled:

1. From the Start Menu, select Programs.
2. Choose Math Invaders and then the Math Invaders icon.

__________________________________________________________________

TO UNINSTALL MATH INVADERS:

1. From the Start Menu, select Programs.
2. Choose Math Invaders and then the Uninstall icon.

You can also uninstall Math Invaders from your Control Panel -
Add/Remove Program Items.

__________________________________________________________________

KEYBOARD/MOUSE CONTROLS:

The following list describes the standard keyboard and mouse controls
	(Press F5 to toggle between the two control modes)
	left mouse button			- move in direction of cursor
	Numpad 8				- Move Forward
	Numpad 2				- Move Backward
	Numpad 4				- Rotate to Left
	Numpad 6				- Rotate to Right
	Z					- Slide to left
	X					- Slide to Right
	Alt					- Accelerate Movement
	Numpad 3				- Look Down
	Numpad 9				- Look Up
	Numpad 5				- Center the view
	S					- Jump up
	C					- Crouch down
	Space					- Activate switch or door
	Control or right mouse button		- Fire weapon
	1 - 7					- Switch to weapon 1 - 7
	[					- Switch to previous item
	]					- Switch to next item
	Enter					- Use current item
	Esc					- Exit the game
	TAB					- Toggle Overhead/Player Views

The following list describes the alternate keyboard and mouse controls
	(Press F5 to toggle between the two control modes)
	A					- Move Forward
	Z					- Move Backward
	Left arrow or move mouse to left	- Rotate to Left
	Right arrow or move mouse to right	- Rotate to Right
	Shift					- Slide to left
	X					- Slide to Right
	Alt					- Accelerate Movement
	Up arrow or move mouse to forward	- Look Down
	Down arrow or move mouse to backward	- Look Up
	S					- Jump up
	C					- Crouch down
	Space					- Activate switch or door
	Control or left mouse button		- Fire weapon
	1 - 7					- Switch to weapon 1 - 7
	Right mouse button			- Switch to next weapon
	[					- Switch to previous item
	]					- Switch to next item
	Enter					- Use current item
	Esc					- Exit the game
	TAB					- Toggle Overhead/Player Views


Additional Overhead View Controls
	NumPad 8				- Move camera up
	NumPad 2				- Move camera down
	NumPad 4				- Move camera to left
	NumPad 6				- Move camera to right
	NumPad 7				- Move camera directly behind player
	NumPad +				- Zoom In
	NumPad -				- Zoom Out

Other Controls
	F1					- Save or Restore game
	F2					- Reduce game window size
	F3					- Enlarge game window
	F5					- Toggle between standard and
							alternate controls
	F6					- Toggle between high and low
							detail modes
	F7					- Quick Save
	F8					- Quick Load


__________________________________________________________________

TECHNICAL SUPPORT

We hope that your experience with Math Invaders will be problem-free.
But if you have any technical problems, please call Technical Support at
(303) 739-4020.


Upon installing MATHINV.EX_ is copied to the installation directory and renamed to MATHINV.EXE, of course. Let’s overlook this file for now and instead take a look in the PAKS directory:

📁 LEVELS
📁 VIDEO
📄 GAME.PAK

LEVELS contains LP##.PAK files, where ## is a two-digit number from 01 to 27. Video contains (unsurprisingly) several AVI files, as this game has a few full motion video “FMV” sequences at startup and shutdown.

PAK Files and pakrat

Let’s poke at GAME.PAK in a hex editor. The first ~5K of the GAME.PAK file looks like this:

0000h  56 00 00 00 57 61 76 65 73 5C 43 6C 69 63 6B 2E  V...Waves\Click.
0010h  77 61 76 00 00 00 00 00 00 00 00 00 00 00 00 00  wav.............
0020h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0030h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0040h  00 00 00 00 20 17 00 00 42 6D 70 73 5C 43 75 72  .... ...Bmps\Cur
0050h  73 6F 72 2E 62 6D 70 00 00 00 00 00 00 00 00 00  sor.bmp.........
0060h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0070h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0080h  00 00 00 00 00 00 00 00 E8 32 00 00 42 6D 70 73  ........è2..Bmps
0090h  5C 46 6F 6E 74 2E 62 6D 70 00 00 00 00 00 00 00  \Font.bmp.......
00A0h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00B0h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00C0h  00 00 00 00 00 00 00 00 00 00 00 00 20 4B 00 00  ............ K..
00D0h  42 6D 70 73 5C 49 68 69 67 68 73 63 6F 2E 62 6D  Bmps\Ihighsco.bm
00E0h  70 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  p...............
00F0h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0100h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................

And the remainder of the file is various binary data. In fact, immediately after the ~5K run above we see the following header, immediately recognizable as a Waveform Audio File Format header:

1720h  52 49 46 46 C0 1B 00 00 57 41 56 45 66 6D 74 20  RIFFÀ...WAVEfmt

This lines up with the file extension of the first string we see at the beginning of the file, Waves\Click.wav. A little deduction shows that the ~5K prelude area is structured as follows:

struct prelude {
    uint32_t count;
    struct entry {
        char name[64];
        uint32_t offset;
    } entries[];
}

Or, in english, we have first four bytes (a little-endian unsigned integer) representing the number of resource headers in the list. This is followed by that number of entries, each of which is a 64-character ASCII string followed by a four-byte offset into the PAK file where the data for that file resides. We use a little C trick here called a “flexible array member” to index past the end of our C struct. Note that each entry doesn’t need to store the length of the file - this is calculated from the offset of the next file in the list or (in the case of the last entry) the end of the PAK file itself.

Armed with this knowledge, let’s write a simple program to “extract” PAK files, which we’ll call pakrat. The program will take the targeted PAK file as a command-line argument and extract the contents to the current working directory. Let’s get started with this:

#include <iostream>
#include <fstream>
#include <cstring>
#include <cerrno>

int main(int argc, char* argv[]) {
	if (argc < 2) {
		std::cerr << "Usage: " << argv[0] << " FILE\n";
		return 1;
	}
	std::cout << ("PAKrat 0.1\n");


	std::ifstream file(argv[1], std::fstream::in | std::fstream::binary);
	if (!file) {
		std::cerr << "Error opening '" << argv[1] << "': " << std::strerror(errno) << "\n";
		return 1;
	}

	file.seekg(0, std::ifstream::end);
	size_t file_size = file.tellg();
	std::cout << "File '" << argv[1] << "' size: " << file_size << " Bytes\n";
	file.seekg(0, std::ifstream::beg);
}

Running it against GAME.PAK produces:

PAKrat 0.1
File '../GAME.PAK' size: 22984537

So far so good! Continuing on (you’ll also need to #include <iomanip>, and add the struct prelude we defined before):

// Let's start by getting the number of entries, so we know how large a buffer to allocate
char* buffer = (char*)malloc(sizeof(uint32_t));
file.read((char*)buffer, sizeof(uint32_t));
uint32_t count = *(uint32_t*)buffer;
std::cout << "File contains " << *(uint32_t*)buffer << " entries:\n";

// Reallocate to the appropriate size.
buffer = (char*)realloc((void*)buffer, sizeof(prelude) + (sizeof(prelude::entry) * count));
file.read(buffer + 4, sizeof(prelude::entry) * count);

// Interpret by casting to a prelude, then print all the files and their offsets.
prelude* header = (prelude*)buffer;
for (auto i = 0; i < header->count; ++i) {
	std::cout << "0x" << std::hex << std::setw(8) << std::setfill('0') << header->entries[i].offset
			  << " " << header->entries[i].name << "\n";
}

We now output:

PAKrat 0.1
File '../GAME.PAK' size: 22984537 Bytes
File contains 86 entries:
0x00001720 Waves\Click.wav
0x000032e8 Bmps\Cursor.bmp
0x00004b20 Bmps\Font.bmp
0x00009a58 Bmps\Ihighsco.bmp
--- ✂️ ---

Excellent! Let’s refactor that last for loop a bit though:

// Interpret by casting to a prelude, gather, then print all the files and their offsets.
prelude* header = (prelude*)buffer;
std::vector<std::tuple<char*, uint32_t, uint32_t>> entries;
for (auto i = 1; i < header->count; ++i) {
	auto& entry = header->entries[i];
	auto &prev = header->entries[i - 1];
	entries.push_back(std::make_tuple(prev.name, prev.offset, entry.offset - prev.offset));
}
auto& last = header->entries[header->count - 1];
entries.push_back(std::make_tuple(last.name, last.offset, file_size - last.offset));

There, now we have made a more manageable list, including sizes. Let’s add some code to print it out. Sorry for the std::ios cruft, formatting C++ streams is a constant annoyance:

for (auto& entry : entries) {
	std::ios old_state(nullptr);
	old_state.copyfmt(std::cout);
	std::cout << "0x" << std::hex << std::setw(8) << std::setfill('0') << std::get<1>(entry)
			  << " " << std::get<0>(entry) << " ";
	std::cout.copyfmt(old_state);
	std::cout << std::get<2>(entry) << " Bytes\n";
}
PAKrat 0.1
File '../GAME.PAK' size: 22984537 Bytes
File contains 86 entries:
0x00001720 Waves\Click.wav 7112 Bytes
0x000032e8 Bmps\Cursor.bmp 6200 Bytes
0x00004b20 Bmps\Font.bmp 20280 Bytes
0x00009a58 Bmps\Ihighsco.bmp 346040 Bytes
--- ✂️ ---

Nearly there! The last push is just to extract the files (you’ll want to add #include <filesystem> for filesystem operations and #include <algorithm> for std::replace)!

// Extract files
for (auto& entry : entries) {
	char* path_str = std::get<0>(entry);
	uint32_t offset = std::get<1>(entry);
	uint32_t length = std::get<2>(entry);

	// Replace Windows path separators
	std::replace(path_str, path_str + strlen(path_str), '\\', '/');

	std::filesystem::path path(path_str);
	auto filename = path.filename();
	auto parent = path.parent_path();

	// Create parent folder(s) (if needed)
	if (!parent.empty() && !std::filesystem::exists(parent)) {
		std::cout << "Creating directory " << std::quoted(parent.c_str()) << '\n';
		std::filesystem::create_directories(parent);
	}

	std::cout << "Creating file " << std::quoted(path_str) << '\n';
	std::ofstream out_file(path, std::fstream::out | std::fstream::binary);
	if (!out_file) {
		std::cerr << "Error creating file: " << std::strerror(errno) << "\n";
		continue;
	}

	// Seek to the correct location and copy the file in 1KiB chunks
	file.seekg(offset, std::ifstream::beg);
	uint32_t to_read = length;
	do {
		char buffer[1024];
		auto chunk = std::min((size_t)to_read, sizeof(buffer));
		to_read -= chunk;
		file.read(buffer, chunk);
		out_file.write(buffer, chunk);
	} while (to_read > 0);
}
PAKrat 0.1
File '../GAME.PAK' size: 22984537 Bytes
File contains 86 entries:
0x00001720 Waves\Click.wav 7112 Bytes
0x000032e8 Bmps\Cursor.bmp 6200 Bytes
0x00004b20 Bmps\Font.bmp 20280 Bytes
0x00009a58 Bmps\Ihighsco.bmp 346040 Bytes
--- ✂️ ---
Creating directory "Waves"
Creating file "Waves/Click.wav"
Creating directory "Bmps"
Creating file "Bmps/Cursor.bmp"
Creating file "Bmps/Font.bmp"
Creating file "Bmps/Ihighsco.bmp"
--- ✂️ ---

And that’s it! You can find the full source code in this GitHub repository. Here’s a sample of an extracted asset! This is Waves\Glose2a.wav, an one of 3 randomized clips that play when you lose a level:

There are also GUI elements in Bmps, for example the weapon sprite sheet Weapons.bmp:

weapon sprite sheet

There’s even an exit splash screen graphic that is unused, that indicates that the game probably had a shareware or beta release:

beta exit splash


Now, attentive readers may have noticed something; If the PAK prelude is 4+(68×86)=5852 Bytes, but the first asset (Waves\Click.wav) starts at 0x1720 (Byte 5920), then what is in the interstitial 68 bytes? Let’s take a look:

  • Last entry name and offset.
  • Fist file data.
1690h  00 00 00 00 D5 91 50 01 57 61 76 65 73 5C 47 6C  ....Õ‘P.Waves\Gl
16A0h  6F 73 65 33 62 2E 77 61 76 00 00 00 00 00 00 00  ose3b.wav.......
16B0h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
16C0h  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
16D0h  00 00 00 00 00 00 00 00 B7 21 5A 01 00 00 00 00  ........·!Z.....
16E0h  BC 42 59 81 00 00 00 00 8C 83 59 81 8C 83 59 81  ¼BY.....ŒƒY.ŒƒY.
16F0h  88 83 59 81 3B AE F7 BF 00 20 56 81 00 00 00 00  ˆƒY�;®÷¿. V.....
1700h  8C 83 59 81 DB AE F7 BF 8C 83 59 81 DE DA F7 BF  ŒƒY.Û®÷¿ŒƒY.ÞÚ÷¿
1710h  8C 83 59 81 8C 83 59 81 E2 13 F7 BF 59 B7 5E 01  ŒƒY.ŒƒY.â.÷¿Y·^.
1720h  52 49 46 46 C0 1B 00 00 57 41 56 45 66 6D 74 20  RIFFÀ...WAVEfmt

And honestly… I don’t know. This space being the same length as the other asset headers makes me think whatever they used to create these PAK files has an off-by-one error, and just wrote an extra entry past the end of their buffer into uninitialized (or maybe stack/heap) memory. Or, it could be a tightly packed block of some unknown flags or parameters to the game engine.

A Short Aside About PAK

Digging further into the LP##.PAK file for specific levels (in this case, LP01.PAK) reveals additional asset types:

  • 📂 Anims\
    • 📄 Anims.lst
  • 📂 Levels\
    • 📄 GameData.dat
    • 📄 Lp01.lev
  • 📂 Mazes\
    • 📂 LP01\
      • 📄 lp01.lws
      • 📄 rlp01.wad
      • 📄 wlp01.bsp
  • 📂 Objects\
    • A variety of .bsp/.BSP files.
  • 📂 Waves\
    • A variety of .WAV files.
  • 📂 anims\ (note the case sensitivity)
    • 58 directories, themselves containing .pcx and .pcxF files.
  • 📂 textures\
    • 📄 Lp01.lst
    • 1345 additional .pcx and .pcxF files.

Now wait a second… .pak, .bsp, .wad… Sounds an awful lot like id Tech 2 (the Quake engine)! However, digging into it, id’s pak format is different, and these wad and bsp files won’t open in any Tech 2 editors I can find. So perhaps the developers of this engine merely took a lot of inspiration, and/or heavily modified and simplified these formats away from the Tech 2 specifications.

This engine is almost a midway point (in capability) between Tech 1 (DOOM) and Tech 2 (Quake). It supports angled floors and vertical viewing angle like Quake, but also only supports sprite-based creatures like Doom.


In the next part, we’ll explore trying to reverse engineer where this game stores its settings, and see if we can’t uncover some secrets in the binary itself.