Reverse Engineering a Windows 95 Game
Reversing Asset Storage
- Reverse Engineering a Windows 95 Game – Reversing Asset Storage (Part I)
- Reverse Engineering a Windows 95 Game – Reversing (Undocumented) Settings (Part II)
- Reverse Engineering a Windows 95 Game – Editor Mode, and Conclusion (Part III)
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
:
There’s even an exit splash screen graphic that is unused, that indicates that the game probably had a shareware or beta release:
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.
- A variety of
- 📂
Waves\
- A variety of
.WAV
files.
- A variety of
- 📂
anims\
(note the case sensitivity)- 58 directories, themselves containing
.pcx
and.pcxF
files.
- 58 directories, themselves containing
- 📂
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.
- Reverse Engineering a Windows 95 Game – Reversing Asset Storage (Part I)
- Reverse Engineering a Windows 95 Game – Reversing (Undocumented) Settings (Part II)
- Reverse Engineering a Windows 95 Game – Editor Mode, and Conclusion (Part III)
Comments from Mastodon
You can leave a comment by replying to this Mastodon post from any ActivityPub-capable social network that can exchange replies with Mastodon.
Comment support inspired by Cassidy James (@[email protected]) and some code borrowed from Julian Fietkau (@[email protected]).