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 MODE1 trackThese are tracks meant for storing regular data, such as a file system. They are usually skipped by audio players. at the start containing the game files, followed by four AUDIO tracks:
CD01.cue
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.
Pettson3.ini
[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.
Extracting the Embedded Script
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:
director.hexpat
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.
Terminal window
./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.
MovieScript 1.lasm
-- ...
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:
MovieScript 1.ls
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:
MovieScript 1.lasm
< 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.
Grepping Error Message 2
➜ 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.
BehaviorScript 36 - Call Copy-X CD protection DLL (OPTGRAPH.DLL).ls
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.
Ghidra Decompile - OPTGRAPH.DLL
// ...
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.
Ghidra Assembly Listing - OPTGRAPH.DLL
**************************************************************
* 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)
patch_findus4.py
#!/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!
Author: WerWolv
Post: Getting old Macromedia Director Games to run on modern Hardware
Link: https://werwolv.net/posts/macromedia_copy_protection/
Source: @WerWolv/Blog | posts//index.mdx
License: CC BY-NC-SA 4.0