Oude Macromedia Director-games draaiend krijgen op moderne hardware

Introductie

Toen ik in het begin van de jaren 2000 een kind was, bracht ik veel tijd door met het spelen van spellen die ik leende van de lokale bibliotheek. Velen daarvan waren Duitse point-and-click adventure-games, omdat dat de spellen waren die werkten op de Windows 95-computer van mijn vader. Zelfs toen al was ik geïnteresseerd in technologie en probeerde ik altijd uit te zoeken hoe ik deze spellen kon blijven spelen nadat ik ze aan de bibliotheek had teruggegeven, maar dat lukte me destijds natuurlijk nooit.

Een paar dagen geleden stuitte ik opnieuw op een van die mislukte pogingen: een set cd's waarop ik destijds had geprobeerd een kopie van "Findus bei den Mucklas" te branden. Ik dacht dat dit wel eens het moment zou kunnen zijn waarop ik eindelijk de vaardigheden bezit om het wél te doen!

Een disk-image verkrijgen

De cd die ik destijds had gebrand was overduidelijk onbruikbaar. Dit kwam enerzijds omdat cd-branders in die tijd vaak niet in staat waren om kopieerbeveiligde cd's correct te lezen of te schrijven, en anderzijds omdat ik de afgelopen tien jaar geen computer met een cd-station heb gehad.

Gelukkig heeft een behulpzame ziel een set volledige disk-images voor dit spel en alle andere spellen in de serie geüpload naar archive.org. Het is geen piraterij als het daarop staat, toch?

De image uitpakken

Het spel bestond uit een set van drie bestanden: een .ccd, een .cue en een .img-bestand. Deze worden blijkbaar geproduceerd door het oude CloneCD-programma en bevatten de ruwe gegevens van de disc, plus aanvullende out-of-band informatie zoals track-metadata. Spellen uit die tijd werden vaak geleverd met audiotracks, zodat je deze cd's gewoon in een cd-speler kon stoppen om de soundtrack of andere gebundelde media te beluisteren.

Een blik op het .cue-bestand laat zien dat er aan het begin een MODE1-track staat (tracks bedoeld voor het opslaan van reguliere gegevens, zoals een bestandssysteem, die meestal door audioplayers worden overgeslagen), gevolgd door vier 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

Om bij de gegevens te komen, gebruikte ik de oude ccd2iso-tool. Deze faalde toen hij de eerste AUDIO-track raakte, maar was nog wel in staat om het begin van het bestand om te zetten naar een reguliere ISO die kon worden gemount.

Kopieerbeveiliging 1

Tijdens het mounten van het bestand en het kopiëren van de gegevens, stuitte ik op wat volgens mij de eerste kopieerbeveiligingsmaatregel was: sommige bestanden lijken meerdere keren in de disk-image voor te komen. Eén keer als de echte kopie en één keer als een tweede kopie die ofwel 0 bytes of 256 bytes lang was en willekeurige onzin (garbage) bevatte.

Mijn vermoeden is dat ze dit expres hebben gedaan om sommige tools te misleiden, zodat deze alleen de nepkopie van het bestand tonen in plaats van de echte, door bewust misvormde directory-vermeldingen op de schijf aan te maken. Windows lijkt hier altijd het juiste bestand te kunnen lezen (anders zou het daar ook niet werken), maar met de verschillende tools die ik probeerde, toonde elke tool uiteindelijk een andere combinatie van bestanden.

Door een combinatie van PeaZip, Ark, Dolphin en 7z te gebruiken, ben ik er uiteindelijk in geslaagd de juiste bestanden uit de ISO te extraheren, maar dit was grotendeels handwerk: het kiezen van de bestanden met de juiste grootte en het overslaan van de lokbestanden.

Op dit punt leek het het eenvoudigst om gewoon de installer uit te voeren, en verrassend genoeg werkte dat. Ik voerde het uit met WINEPREFIX=$(pwd)/prefix wine "Installiere Findus4.exe" om het in een apart Wine-prefix te installeren, waarna ik de geïnstalleerde gegevens kon kopiëren.

Kopieerbeveiliging 2 - Electric Boogaloo

Het uitvoeren van Findus4.exe werkte natuurlijk niet direct, maar toonde in plaats daarvan een foutmelding waarin stond dat de cd ontbrak.

Door simpelweg te zoeken naar die melding met rg -a "Lege die CD", ontdekte ik dat deze gewoon uit het bestand Pettson3.ini werd gelezen. Ze hadden blijkbaar niet eens de moeite genomen om dat bestand vanaf het vorige spel te hernoemen.

Pettson3.ini

[CDROM]
CDROM=?
[GAME1]
Vind=0
[ALERT1]
MESSG=Lege die CD Findus4 ein und versuche es noch einmal.
[SCREEN]
FULLSCREEN=1

Het zoeken naar een paar strings leidde er uiteindelijk toe dat messg werd gevonden in Findus4.exe. Mijn eerste instinct was om het binary-bestand in Ghidra te laden en ernaar te zoeken. Verrassend genoeg verscheen de string nergens via de optie Search -> For Strings..., wat er meestal op wijst dat de string niet echt deel uitmaakt van de PE-data, maar een soort extra payload is die aan het einde van het uitvoerbare bestand is toegevoegd.

Het ingebedde script extraheren

Na wat verder onderzoek ontdekte ik dat deze spellen allemaal Macromedia Director Projector-executables zijn. Deze werden gegenereerd door Macromedia Director en bevatten in feite de volledige runtime voor het interpreteren van Director-bestanden, met een .dxr-bestand dat eraan is geconcateneerd. Dat bestand is wat wordt uitgevoerd wanneer het programma start. Het was logisch dat de cd-controle deel uitmaakte van het ingebedde Director-bestand, want waarom zou de runtime anders lezen uit een configuratiebestand genaamd Pettson3.ini?

Tijdens mijn onderzoek stuitte ik op ProjectorRays, een disassembler en decompiler voor precies deze bestanden. Ze hebben de bestandssignatuur RIFX of XFIR, afhankelijk van de endianness van het doelsysteem, wat ze gemakkelijk identificeerbaar maakt. Met ImHex vond ik een paar voorkomens van deze strings in het binary-bestand. Gebaseerd op de broncode van ProjectorRays begint het formaat van deze .dxr-bestanden als volgt:

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!");
};

Slechts twee van de XFIR-voorkomens hadden een geldig type erachter: één was APPL op adres 0x00222151, wat volgens ScummVM een Director-applicatiewrapper identificeert (het executable-bestand waar we mee werken), en de andere was MV93 op adres 0x002222F0, wat het reguliere, ongecomprimeerde Director-moviescript lijkt te zijn.

Aanvankelijk probeerde ik dat fragment uit het executable-bestand te kopiëren en apart te verwerken met ProjectorRays, maar het bleek dat alle offsets die in het toegevoegde bestand worden gebruikt absoluut zijn en rekening houden met het feit dat het .dxr-bestand niet aan het begin van het executable-bestand staat. In plaats daarvan heb ik een --offset optie aan de CLI toegevoegd, waarmee je vooruit kunt zoeken naar de werkelijke offset van het ingebedde binary-bestand.

Het Director-bestand disassembleren

Uiteindelijk kunnen we de inhoud van het ingebedde Director-bestand dumpen met het volgende commando:

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

Resultaat in de output-map:

  • Findus4/casts/Internal/MovieScript 1.lasm (disassembly-bestand)
  • Findus4/casts/Internal/MovieScript 1.ls (gedecompileerde versie van het script)
  • Findus4.dir

Tot mijn verbazing genereerde dit zowel een disassembly-bestand (.lasm) als een gedecompileerde versie van het script (.ls), waarin alle originele functie- en variabelenamen bewaard zijn gebleven!

Het script analyseren

Het gegenereerde bestand is slechts ongeveer 150 regels lang en bevat de initialisatielogica van het spel. Het is geschreven in een programmeertaal genaamd Lingo, die door Macromedia Director wordt gebruikt. Zelfs zonder de taal te kennen, is het vrij eenvoudig te lezen. Het duurde niet lang voordat ik de functie vond waar de "messg"-string oorspronkelijk vandaan kwam.

Zoals hier te zien is, hebben ze een primitieve .ini-bestand-parser geïmplementeerd om het Pettson3.ini-bestand te parsen en er een paar configuratiewaarden uit te halen, waaronder de foutmelding die wordt getoond als er geen cd kan worden gevonden.

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
-- ...

Ik vind het altijd erg leuk om deze oude scripts te lezen en te kijken naar de vreemde mix van gebroken Engels, identifiers geschreven in vreemde talen (in dit geval Zweeds, denk ik?) en waarschijnlijk extreem buggy code.

Iets verderop vinden we de functie waar de fouttekst daadwerkelijk wordt gebruikt:

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
-- ... Deel van setSearchPaths functie
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
-- ...

Ook hier: zeer eenvoudige code. Ze lopen door alle mogelijke Windows-schijven om er een te vinden die een cd-rom bevat met de map p3media erop. Als die wordt gevonden, worden de zoekpaden ingesteld. Anders wordt de foutmelding getoond en roept het spel quit() aan.

Het script patchen

Na het analyseren van de setSearchPaths-functie bedacht ik dat de eenvoudigste manier om de quit()-branch te vermijden (terwijl de zoekpaden nog steeds correct worden ingesteld), was om de if zo te patchen dat deze naar de volgende instructie springt in plaats van naar de else-branch als de check mislukt. Dat zou ertoe leiden dat de cd-paden wat vreemd worden, maar ik dacht dat het waarschijnlijk nog steeds zou werken, aangezien het spel een "Full Install" toestaat waarbij alle benodigde gegevens naar de harde schijf worden gekopieerd.

In het assembly-bestand zijn dit de instructies die we willen patchen:

MovieScript 1.lasm

< 95 00 60       > [246] jmpifz [342] ......... if not voidp(driveletter) then / else
< 4C 08          > [249] getlocal 8 ........... <ini>

ProjectorRays ondersteunt helaas nog niet het terugassembleren van de code, maar gelukkig is de Lingo-bytecode ongelooflijk eenvoudig, dus we kunnen dit handmatig doen met ImHex. Elke instructie begint met een opcode-byte, gevolgd door een big-endian integer van 1, 2 of 4 bytes lang.

De patching: jmpifz heeft de opcode-byte 0x95, gevolgd door de big-endian 16-bit relatieve offset 0x0060 (96 bytes), wat aangeeft waarheen gesprongen moet worden. Om simpelweg naar de volgende instructie te springen, moeten we 3 bytes vooruit springen (van offset 246 naar offset 249). We moeten de instructie-bytes dus patchen naar < 95 00 03 >.

Het zoeken naar de originele byte-sequentie leverde slechts één resultaat op op adres 0x002252E8, dat ik heb overschreven met de nieuwe instructie. En daarmee speelde de introvideo van het spel!

Kopieerbeveiliging 3 - Rise of the Machines

Helaas was er meer. Er verscheen een nieuwe foutmelding: "Disc Error !".

Door opnieuw te zoeken naar die string, vond ik twee resultaten, en ik besloot te beginnen met intro.dxr. Het opnieuw in ProjectorRays gooien resulteerde in een paar scripts, waarvan er één heel eerlijk BehaviorScript 36 - Call Copy-X CD protection DLL (OPTGRAPH.DLL).ls heette. Ik had niet zoveel hulp nodig, ontwikkelaars, maar bedankt voor de tip.

Daarin stond code die de optgraph.dll (gelegen naast het executable-bestand) laadt en de functie initdisplay() aanroept. Vervolgens wordt de retourwaarde van die functie gecontroleerd; als deze niet overeenkomt met een bepaalde "magic value", wordt het spel opnieuw afgebroken.

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

Interessant genoeg hadden ze de kopieerbeveiliging voor Mac niet eens geïmplementeerd...

Om langs deze controle te komen, hadden we een paar opties: het spel doen geloven dat het op een Mac draait, de vergelijking met het magische getal patchen, of de DLL patchen zodat deze altijd die constante retourneert. Aangezien dezelfde controle ook in huset.dxr is geïmplementeerd en ik geen zin had om de runtime te reverse-engineeren en de platformdetectie te patchen, besloot ik optgraph.dll te patchen.

De support-DLL patchen

Het openen van de DLL in Ghidra liet zien dat deze in feite alleen die ene functie bevatte en niets anders. initdisplay() zelf is een nogal verwarrende chaos waarbij een reeks low-level commando's naar de cd-drive worden gestuurd om op een bepaalde manier die magische waarde te construeren.

Ik heb niet te veel tijd besteed aan het reverse-engineeren hiervan, maar heb in plaats daarvan de Patch Instruction-optie van Ghidra gebruikt om de eerste paar instructies te vervangen door een simpele MOV en 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

Na een gênant lange tijd worstelen om Ghidra de DLL weer correct te laten exporteren, lukte dit uiteindelijk via File -> Export -> Original File. Dit leek het laatste te zijn dat nodig was, aangezien het spel nu volledig doorstart tot het hoofdmenu!

Een automatische patcher schrijven

Nadat ik klaar was, schreef ik een kort Python-script dat de patches uit dit artikel kan toepassen. Dit specifieke script werkt alleen voor de Duitse versie van het spel. Het zou echter niet moeilijk moeten zijn om dit aan te passen voor andere versies (waarschijnlijk is alleen het aanpassen van de hash en de offset nodig).

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}")

Afronding

Nadat ik de avond heb doorgebracht met het opnieuw spelen van dit spel, heb ik ook naar een paar andere, vergelijkbare spellen gekeken. Het blijkt dat een verrassend groot aantal van hen Macromedia gebruikte en in feite hetzelfde basismechanisme voor kopieerbeveiliging had. Helaas is er geen eenvoudige manier om één algemene patch te maken die voor al deze spellen werkt, maar met een beetje inspanning kan hetzelfde proces op hen worden toegepast.

Het feit dat ik deze spellen na meer dan 20 jaar weer kan spelen, en misschien zelfs mijn toekomstige kinderen kan laten spelen, maakt me erg gelukkig. Ik hoop echt dat dit bericht anderen dezelfde vreugde kan brengen!