Getting old Macromedia Director Games to run on modern Hardware | WerWolv

· WerWolv

14 min read Original article ↗

Introduction

When I was a kid in the early 2000s, I spent a lot of time playing games that I borrowed from our local library. Many of them were German point-and-click adventure games, since those were the games that ran on my dad’s Windows 95 computer. Even back then, I was interested in tech and always tried to figure out how I could keep playing these games after giving them back to the library, but I, of course, never managed to do it. A few days ago, I stumbled across one of those failed attempts again: a set of CDs onto which I tried to burn a copy of “Findus bei den Mucklas.” I figured that maybe now was the time where I finally have the skills to do it!

Getting a Disk Image

Obviously, the CD I burned back then was unusable, both because CD burners at the time often weren’t able to properly read or write copy-protected CDs and because I haven’t had a computer with a disc drive in the past 10 years.

disappointment.png
disappointment.png

Thankfully, some helpful soul uploaded a set of full disk images for that and all the other games in the series to archive.org. It’s not piracy if it’s on there, right?

Unpacking the Image

The game came as a set of three files: a .ccd, a .cue and a .img file. Those are apparently produced by the old CloneCD program and contain the raw data from the disc, plus additional out-of-band information such as track metadata. Games from that time often came with audio tracks as well, so you could just pop these CDs into a CD player and listen to the soundtrack or other bundled media.

Looking at the .cue file shows that there’s a at the start containing the game files, followed by four AUDIO tracks:

FILE "CD01.img" BINARY

TRACK 1 MODE1/2352

INDEX 1 00:00:00

TRACK 2 AUDIO

INDEX 1 45:08:37

TRACK 3 AUDIO

INDEX 1 46:38:44

TRACK 4 AUDIO

INDEX 1 47:08:50

TRACK 5 AUDIO

INDEX 1 47:11:58

To access the data, I just used the old ccd2iso tool, which failed when it hit the first AUDIO track but was still able to convert the start of the file into a regular ISO that could be mounted.

Copy Protection 1

Mounting the file and trying to copy the data over, I ran into what I believe to be the first copy protection measure:

Duplicate files popup while extracting the archive
Duplicate files popup while extracting the archive

Some of the files seem to be in the disk image multiple times: once as the real copy and once as a second copy that was either 0 bytes or 256 bytes long and contained random garbage. My guess is that they did that on purpose to trick some tools into displaying only the fake decoy copy of the file instead of the real one by creating deliberately malformed directory entries on the disk. Windows seems to be able to always read the correct file here (otherwise it wouldn’t work there either of course) but with the different tools I tried, each one of them showed a different combination of files in the end.

Using a combination of PeaZip, Ark, Dolphin and 7z, I ultimately managed to extract the right files from the ISO, but it was mostly manual labour: choosing the files that were the right size and skipping the decoy ones.

Final files that were extracted from the ISO image
Final files that were extracted from the ISO image

At this point, just running the installer seemed easiest, and it surprisingly worked. I ran it using WINEPREFIX=$(pwd)/prefix wine "Installiere Findus4.exe" to make it install into a separate Wine prefix, from which I could copy the installed data afterwards.

Copy Protection 2 - Electric Boogaloo

Executing Findus4.exe now, of course, didn’t just work, but instead showed a nice error popup saying that the CD was missing.

Missing CD Error popup
Missing CD Error popup

Simply searching for that message using rg -a "Lege die CD" showed that they just read it from the Pettson3.ini file. They apparently didn’t even bother renaming that file from the previous game.

[CDROM]

CDROM=?

[GAME1]

Vind=0

[ALERT1]

MESSG=Lege die CD Findus4 ein und versuche es noch einmal.

[SCREEN]

FULLSCREEN=1

Searching for a few strings ultimately led to messg being found in Findus4.exe. My first instinct was, of course, to just throw the binary into Ghidra and start looking for it. Surprisingly, the string didn’t show up anywhere using the Search -> For Strings... option, which usually means that the string isn’t actually part of the PE data but some sort of extra payload appended to the end of the executable file.

After some more digging, I found that these games are all Macromedia Director Projector executables. These were generated by Macromedia Director and basically contain the whole runtime for interpreting Director files, with a .dxr file concatenated onto the end. That file is what gets executed when the program runs. It made sense that the CD check would be part of the embedded Director file, because why would the runtime read from a config file called Pettson3.ini?

During my research, I stumbled across ProjectorRays, which is a disassembler and decompiler for exactly these files. They have the file signature RIFX or XFIR, depending on the endianness of the target system, which makes them pretty easy to identify. Using ImHex, I found a few occurrences of those strings in the binary. Based on the ProjectorRays source code, the format of these .dxr files starts with something like this:

enum RifxType : u32 {

MV93 = le u32("MV93"),

MV95 = le u32("MV95"),

FGDM = le u32("FGDM"),

FGDC = le u32("FGDC"),

APPL = le u32("APPL")

};

struct Content {

type::Size<u32> length;

RifxType type;

// ...

};

struct DirectorFile {

char magic[4];

if (magic == "RIFX")

be Content content [[inline]];

else if (magic == "XFIR")

le Content content [[inline]];

else

std::error("Invalid Director File!");

};

Only two of the XFIR occurrences had a valid type after them: one was APPL at address 0x00222151, which, according to ScummVM, identifies a Director application wrapper (the executable file we are working with), and the other was MV93 at address 0x002222F0, which seems to be the regular, uncompressed Director movie script.

I initially tried to copy that chunk out of the executable and process it separately using ProjectorRays, but it turned out that all the offsets used inside the appended file are absolute and account for the .dxr file not starting at the beginning of the executable. Instead, I ended up adding an --offset option to the CLI that lets you seek forward to the actual offset of the embedded binary. The PR can be found here in case it’s useful to somebody else: ProjectorRays#63.

Disassembling the Director File

Finally, after all this trouble, we can dump the embedded Director file’s contents using the following command.

./projectorrays decompile Findus4.exe -o output --dump-scripts --offset 0x002222F0

output

├── Findus4

│ └── casts

│ └── Internal

│ ├── MovieScript 1.lasm

│ └── MovieScript 1.ls

└── Findus4.dir

To my surprise, this generated both a disassembly file (the .lasm file) and a decompiled version of the script (the .ls file), which still contains all the original function and variable names!

Decompiled Director Script
Decompiled Director Script

Analyzing the Script

The generated file is only around 150 lines long and contains the game’s initialization logic. It’s written in a programming language called Lingo, which is what Macromedia Director uses. Even without knowing the language, it’s pretty straightforward to read. It didn’t take long to find the function from which that "messg" string originally came. As seen here, they implemented a primitive .ini file parser to parse the Pettson3.ini file and get a few config values out of it, one being the error message displayed when no CD can be found.

-- ...

on analyseIniText iniText

repeat with mening = 1 to iniText.line.count

ReddString = StripString(iniText.line[mening])

if ReddString contains "[" then

ReddString = StripString(iniText.line[mening + 1])

antalBokstaver = ReddString.char.count

if ReddString contains "cdrom" then

cdromenhet = ReddString.char[7..antalBokstaver]

end if

if ReddString contains "Vind" then

antalBokstaver = ReddString.char.count

vindspelAktivt = integer(ReddString.char[6])

end if

if ReddString contains "messg" then

antalBokstaver = ReddString.char.count

alertmessage = ReddString.char[7..antalBokstaver]

end if

if ReddString contains "fullscreen" then

antalBokstaver = ReddString.char.count

fullscreen = integer(ReddString.char[12])

end if

end if

end repeat

resultat = [#cdromenhet: cdromenhet, #vindspelAktivt: vindspelAktivt, #alertmessage: alertmessage, #screen: fullscreen]

return resultat

end

-- ...

I always find it super fun to read these old scripts and look at the weird mix of broken English, identifiers written in foreign languages (Swedish in this case, I think?) and probably extremely buggy code.

A bit further down, we can also find the function where the error text is actually being used:

on checkpccdrompath

seperator = the last char in the applicationPath

drivelista = ["d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]

Retvar = VOID

repeat with Drive in drivelista

iscdrom = baDiskInfo(Drive, "type")

if iscdrom = "CD-ROM" then

Path = Drive & ":" & seperator & "p3media"

if checkoncd(Path) then

Retvar = Drive

exit repeat

end if

end if

end repeat

return Retvar

end

-- ... Part of setSearchPaths function

driveletter = checkpccdrompath()

if not voidp(driveletter) then

ini[#cdromenhet] = driveletter & ":"

hdMedia = mp & "p3media"

hdljud = mp & "p3media\p3ljud"

hdcast = mp & "p3media\p3cast"

cdMedia = string(ini.cdromenhet & "\p3media")

cdLjud = string(ini.cdromenhet & "\p3media\p3ljud")

cdCast = string(ini.cdromenhet & "\p3media\p3cast")

the searchPaths = [hdMedia, hdljud, hdcast, cdMedia, cdLjud, cdCast]

else

alert(string(ini.alertmessage))

quit()

end if

-- ...

Again, pretty straightforward code. They just loop over all possible Windows drives, trying to find one that contains a CD-ROM with the p3media folder on it. If one is found, the search paths are set up. Otherwise, the error message we saw is displayed and the game calls quit().

Patching the Script

Analyzing the setSearchPaths function a bit, I figured the easiest way to avoid executing the quit() branch while still allowing it to set up the search paths correctly would be to patch the if to jump to the next instruction instead of the else branch if the check failed. That would result in the CD paths being a bit weird, but I figured it would probably still work, since the game lets you do a “Full Install” where it copies all the required data to the hard disk and presumably loads the files from there.

In the assembly file, the instructions we want to patch are the following:

< 95 00 60 > [246] jmpifz [342] ......... if not voidp(driveletter) then / else

< 4C 08 > [249] getlocal 8 ........... <ini>

ProjectorRays sadly doesn’t yet support assembling the code back, but thankfully, the Lingo bytecode is incredibly simple, so we can do it manually with ImHex. Every instruction starts with a single opcode byte, followed by a big-endian integer that’s either 1, 2 or 4 bytes long. To make the manual patching a bit easier, I also made ProjectorRays output the bytes that make up each instruction (also submitted upstream to ProjectorRays).

Now for the patching: jmpifz has the opcode byte 0x95, followed by the big-endian 16-bit relative offset 0x0060, or 96 bytes, indicating where to jump. To simply jump to the next instruction, we need to jump 3 bytes forward (from offset 246 to offset 249), so we want to patch the instruction bytes to be < 95 00 03 >. Searching for the original byte sequence yielded only a single result at address 0x002252E8, which I overwrote with the new instruction.

And with that, the game’s intro video was playing! 🎉

The game's intro video playing
The game's intro video playing

Copy Protection 3 - Rise of the Machines

Sadly, there was more to it.

disappointment2.png
disappointment2.png

Again, simply searching for that string yielded two results, and I decided to start with intro.dxr for now.

➜ rg -a 'Disc Error !'

P3media/intro.dxr

...

P3media/huset.dxr

...

Throwing it into ProjectorRays again resulted in a few more scripts, one of which was straightforwardly called BehaviorScript 36 - Call Copy-X CD protection DLL (OPTGRAPH.DLL).ls. I didn’t need that much handholding, devs, but thanks either way.

Inside was, as the filename suggested, some code that loads the optgraph.dll located next to the game executable and calls its initdisplay() function. It then checks the return value of that function, and if it doesn’t match some magic value, the game is once again aborted.

on exitFrame me

if the platform contains "Mac" then

nothing()

else

if Ok = 0 then

alert("Disc Error !")

avslutaSpelet()

else

if getReturnVal(me) <> 26079537 then

alert("Disc Error !")

avslutaSpelet()

end if

end if

end if

end

Interestingly, they didn’t even bother implementing the copy protection for Mac…

To get past this check, we have a few options. We could make the game believe that it’s running on a Mac, patch the comparison against the magic number or patch the DLL to make it always return that constant. Given that the identical check is implemented again in huset.dxr and that I didn’t really want to reverse engineer the runtime and patch the platform detection, I decided to patch optgraph.dll instead.

Patching the Support DLL

Opening the DLL in Ghidra showed that it essentially contained only that one function and nothing else.

initdisplay() itself is a bit of a convoluted mess where they send a bunch of low-level commands to the CD drive to somehow construct that magic value from it.

// ...

FUN_10001e50(&local_44,0,0x10);

local_3c = 3;

mciSendCommandA(local_c,0x814,0x100);

local_3c = 1;

local_2c = local_40;

mciSendCommandA(local_c,0x814,0x102,&local_44);

local_28 = (uint *)(local_40 & 0xff);

local_3c = 1;

local_48 = (char *)(local_40 >> 8 & 0xff);

local_38 = 4;

local_40._0_2_ = local_40._1_2_;

mciSendCommandA(local_c,0x814,0x112,&local_44);

// ...

I didn’t spend too much time reversing any of this but instead just used Ghidra’s wonderful Patch Instruction option to replace the first few instructions with a plain and boring MOV and RET.

**************************************************************

* FUNCTION *

**************************************************************

int __stdcall initdisplay(void)

assume FS_OFFSET = 0xffdff000

int EAX:4 <RETURN>

0x1060 1 initdisplay

initdisplay

10001060 b8 31 f1 MOV EAX, 26079537

8d 01

10001065 c3 RET

10001066 05 ?? 05h

10001067 00 ?? 00h

10001068 00 ?? 00h

After struggling for an embarrassingly long time to get Ghidra to properly export the DLL again, I finally managed to do it through File -> Export -> Original File.

This seemed to have been the last thing needed, since the game now gets all the way to the menu screen!

Start menu screen of the game
Start menu screen of the game

Writing an auto patcher

After I was done, I wrote a quick Python script that can apply the patches discussed in this post. This exact script will only work for the German version of the Game that I linked above. However, it shouldn’t be hard to adapt to other versions as well (probably only requires fixing the hash and the offset)

#!/usr/bin/env python3

import hashlib

import sys

from pathlib import Path

game_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()

patches = [

(

game_dir / "Findus4.exe",

"b33312ff9086c1cbc09d9cde6baa0eff4220a1ed354c9a3211ff6787fff46e39",

0x2252E8,

bytes.fromhex("95 00 60"),

bytes.fromhex("95 00 03"),

),

(

game_dir / "optgraph.dll",

"9897563fd6447976e2780ba0711f6e4e0a5a98a5c5cbecc4cb9bd608d856bd50",

0x1060,

bytes.fromhex("55 8B EC 81 EC F4"),

bytes.fromhex("B8 31 F1 8D 01 C3"),

),

]

verified = []

for path, expected_hash, offset, original, replacement in patches:

data = bytearray(path.read_bytes())

actual_hash = hashlib.sha256(data).hexdigest()

if actual_hash != expected_hash:

raise SystemExit(f"Unexpected SHA-256 for {path}: {actual_hash}")

if data[offset : offset + len(original)] != original:

raise SystemExit(f"Unexpected bytes at 0x{offset:X} in {path}")

data[offset : offset + len(replacement)] = replacement

verified.append((path, data))

for path, data in verified:

path.chmod(path.stat().st_mode | 0o200)

path.write_bytes(data)

print(f"Patched {path}")

Wrapping Up

After spending the evening playing through that game again, I also looked through a few other, similar games that I used to play. It turns out that a surprisingly large number of them used Macromedia and had essentially the same basic copy-protection mechanism. Sadly, there’s no easy way to make a common patch that works for all of these games, but with a little bit of effort, the same process can be applied to them as well.

Being able to play these games again after over 20 years, and maybe even having my future kid play them, makes me really happy :)

I really hope this post can bring the same joy to others as well!