Uncracking Pac-Man Adventures in Time

Inspiration

A while ago I came across this cool blog post where cpunch reverse engineers the DRM on a 22 year old game:

https://openpunk.com/pages/cracking-22-yr-old-drm/

Reading the blog brought a smile to my face and warmed my heart.

Well, that game was Pac-Man: Adventures in Time and 24 years ago I was the guy who wrote the DRM that’s being reverse engineered in the blog! 😄

There's a video playthrough of the game itself at the bottom of this page. There's also a nice interview with a couple of the other people involved with the creative side of the game here https://twitter.com/DailyPacMan/status/1483899777754484739

Incidentally, I also developed much of the 3D graphics engine and art toolchain used by the game so in the future I'll go into some more details about the rest of the engine and the development of the game.

But for now, I thought it would be fun to explain some of the backstory behind the ‘DRM’ and share the original source code to it!

History: Cracking & Disk Protection

Before I was properly in the games industry, in the early 1990s, I myself used to crack the disk protection schemes on Commodore Amiga games for nerdy fun. I was as interested in how the protection worked as I was the game itself. I wrote my own tools to extract data from Rob Northern format disks, and even cooked up a few of my own schemes (MFM encoding/flux tricks, wide tracks, etc).

Fast forward a few years to Pac-Man: Adventures in Time and I was full of ideas about how to implement fairly secure CD-ROM protection (CAV timing tricks/detection, malformed tracks, malformed ISO9660 headers, etc) to protect sales of the game we were so very proud to have created.

Sadly, the publisher of the game and the management thought the idea of me writing my own protection was too risky (in hindsight, I would agree with them - there are a lot of driver/hardware compatibility risks) – and not worth doing because all the off the shelf protection systems were being cracked within hours of a game’s release anyway. So I didn’t ever get to implement any proper protection – I wonder if I’d be writing this if I had 😉

Is it really DRM?

So what’s this CD check all about?

It was never really intended to be used as DRM.

The publisher requested that the game should only be playable with the CD in the drive - even though the game can be 100% installed to hard disk.

Although we weren’t in favour of doing this, I seem to remember (but may have it wrong, it was a long time ago) their reasoning was actually more to do with consistency between games to make life easier for the staff on their telephone helpdesk – like the first question they could always ask any user who phoned for help is “did you put the game CD in the drive?”.

Lets look at the original code (unreverse engineering?)

So the sub_40E8D0 function in cpunch's analysis is really called InitialiseFileSystem()

Its main purpose is to build a list of all of the paths where the data files used by the game are located.

NOTE the installer for the game has the option of a “minimal install” where some files can be installed on the user’s hard drive and some files (such as the videos) are only accessed from the CD-ROM. In this situation, the game files will be found in multiple places, so multiple paths are tracked.

The logic to find which drive the game CD-ROM is in was just part of setting up these multiple search paths. This was extended at the last minute to also show an error if the CD couldn’t be found.

 1HRESULT InitialiseFileSystem( LPTSTR* paths )
 2{
 3   // *** 3-Mar-2024 note: for clarity I've removed some debug only stuff that used the paths parameter ***
 4
 5  // Check if file system has already been initialised
 6  if ( g_nWin32GameFilePathCount != 0 )	
 7  {
 8    return S_OK;
 9  }
10
11  TCHAR szPath[ _MAX_PATH ];
12  if ( GetModuleFileName( NULL, szPath, MAX_PATH ) )
13  {
14    TCHAR szDrive[ _MAX_DRIVE ];
15    TCHAR szDir[ _MAX_DIR ];
16    _tsplitpath( szPath, szDrive, szDir, NULL, NULL );
17    _tmakepath( szPath, szDrive, szDir, NULL, NULL );
18    _tcscpy( g_szWin32GameFilePaths[g_nWin32GameFilePathCount++], szPath );
19  }
20
21  // Look for a registry key for the application file paths
22  HKEY hKey;
23  LONG lRes = RegOpenKeyEx( HKEY_CURRENT_USER, WIN32FILE_REG_KEY, 0, KEY_READ, &hKey );
24  if ( lRes == ERROR_SUCCESS )
25  {
26    TCHAR szPath[ MAX_PATH ];
27    DWORD dwSize = MAX_PATH;
28    DWORD dwType;
29    lRes = RegQueryValueEx( hKey, WIN32FILE_REG_VALUE, NULL, &dwType, (BYTE*)szPath, &dwSize	);
30    RegCloseKey( hKey );
31
32    if ( lRes == ERROR_SUCCESS )
33    {
34      if (dwSize>0)
35      {
36        szPath[dwSize] = _T('\0');
37
38        // copy path to search list
39        _tcscpy( g_szWin32GameFilePaths[g_nWin32GameFilePathCount++], szPath );
40      }
41    }
42  }
43
44  // *** Path 2 is the CD path
45  g_dwAvailableCDDrives = _sysGetCDDrives();
46  g_dwGameCDDrive = _sysGetGameCDDrive( g_dwAvailableCDDrives );
47  if (g_dwGameCDDrive)
48  {
49    _sysCDLocationToPath( g_szWin32GameFilePaths[g_nWin32GameFilePathCount++], g_dwGameCDDrive );
50  }
51  else
52  {
53#ifdef _DEBUG
54    // debug builds don't moan about no CD in...
55#else
56    // Error - can't find the game CD!!!
57    return SYSERR_NOCD;
58#endif
59  }
60
61  // make sure there is at least one path, otherwise we have a bad install
62  if (g_nWin32GameFilePathCount < 1)
63  {
64    dbgError( "No paths found by Win32File - bad startup\n" );
65    return E_FAIL;
66  }
67
68  pacInit();
69
70  return S_OK;
71}

In more detail:

GetModuleFileName() gets the path the executable was started from. Without the CD check, this actually allows the game to be played from any location, including direct from the CD without installation, and over network shares (we actually did this for playtests in development).

Next, we check a registry key that the installer should have set if the game has already been installed.

Then comes the now famous CD drive handling. This first calls _sysGetCDDrives():

 1// Get a DWORD mask of all CD-ROM drives available
 2DWORD _sysGetCDDrives()
 3{
 4  DWORD dwDrives = GetLogicalDrives();
 5  DWORD dwMask = dwDrives;
 6  unsigned int uiBit = 0;
 7  TCHAR szDriveName[] = _T("A:\\");
 8
 9  // exclude any drives which aren't CD-ROM drives
10  while (dwMask)
11  {
12    if (dwMask & 1)
13    {
14      if (GetDriveType(szDriveName) != DRIVE_CDROM)
15      {
16        dwDrives = dwDrives ^ (1<<uiBit);
17      }
18    }
19
20    ++uiBit;
21    ++szDriveName[0];
22    dwMask = dwMask >> 1;
23  };
24
25  return dwDrives;
26}

GetLogicalDrives() returns a bitmask with a bit set for every logical drive available on the system.

For each drive I call GetDriveType() to ensure that only CD drives are considered and all other types of drive are ignored.

NOTE: this is done every time the game is run because the configuration of the users machine may have changed since the game was last run. Also, with multi-changer drives, the drive letters can jump around depending on which CDs are in the caddy.

Once we know where the CD-ROM drives are, it’s time to find the Pac-Man CD. This is what the _sysGetGameCDDrive() function does:

 1// get drive mask of drive with game CD in or 0 for not found
 2DWORD _sysGetGameCDDrive( DWORD dwAvailableDrives )
 3{
 4  // if no CD drives available, no CD
 5  if (dwAvailableDrives==0)
 6    return 0;
 7
 8  DWORD	dwDrive = 0;
 9
10  // get path where CD was last time [if there was a last time]
11  HKEY hKey;
12  LONG lRes = RegOpenKeyEx( HKEY_CURRENT_USER, WIN32FILE_REG_KEY, 0, KEY_READ, &hKey );
13  if ( lRes == ERROR_SUCCESS )
14  {
15    DWORD dwSize = sizeof(DWORD);
16    lRes = RegQueryValueEx( hKey, WIN32FILE_REG_CDVALUE, NULL, NULL, (BYTE*)&dwDrive, &dwSize );
17    RegCloseKey( hKey );
18  }
19
20  // if last known drive has since dissapeared, don't scan it
21  dwDrive = dwDrive & dwAvailableDrives;
22
23  // exclude last known drive from remaining drive search list
24  dwAvailableDrives = dwAvailableDrives & (~dwDrive);
25
26  // search last known CD drive
27  if (dwDrive)
28  {
29    dwDrive = _sysSearchDrivesForGameCD( dwDrive );
30  }
31
32  // search all remaining CD drives
33  if (!dwDrive)
34  {
35    dwDrive = _sysSearchDrivesForGameCD( dwAvailableDrives );
36  }
37
38  // found the CD in a drive so save it to the registry
39  if (dwDrive)
40  {
41    lRes = RegCreateKeyEx( HKEY_CURRENT_USER, WIN32FILE_REG_KEY, NULL, _T(""), 
42                REG_OPTION_NON_VOLATILE, KEY_WRITE, NULL, &hKey, NULL );
43    if (lRes == ERROR_SUCCESS)
44    {
45      RegSetValueEx( hKey, WIN32FILE_REG_CDVALUE, NULL, REG_DWORD, (BYTE *)&dwDrive, sizeof(DWORD) );
46      RegFlushKey( hKey );
47      RegCloseKey( hKey );
48    }
49  }
50
51  return dwDrive;
52}

First there’s an early out for machines that might not actually have a CD-ROM.

Most people only have one CD drive, so there’s a good chance the CD will in the SAME drive as it was last time. So we check a registry key that was set last time this function was run, which tells us the last known location of the CD.

BUT as mentioned earlier, the configuration of the drives might have changed: dwAvailableDrives is a mask which has a 1 bit for each available drive in the system (bit 0 is A:, bit 1 is B:, bit 2 is C: and so on). dwDrive has a single bit set for the last known drive. The bitwise-AND sets dwDrive to 0 if the last known drive has disappeared.

If the last known drive (dwDrive) is still present in the system, we first search it to see if the game CD is there. That's the first call to _sysSearchDrivesForGameCD().

If the CD is NOT found in the last known drive, the second call to _sysSearchDrivesForGameCD() looks for it in any other CD-ROM drives that might be present on the machine.

If the CD was found, then we set the last known location registry key mentioned above.

The final piece of code to look at is _sysSearchDrivesForGameCD():

 1// work through the paths and search each path for the ID file
 2DWORD _sysSearchDrivesForGameCD( DWORD dwDrives )
 3{
 4  DWORD dwMask = dwDrives;
 5  unsigned int uiBit = 0;
 6  TCHAR szPathName[] = _T("A:\\PAC-MAN.DAT");
 7
 8  DWORD dwLocation = 0;
 9
10  // stop CD insertion errors
11  unsigned int uiOldErrorMode = SetErrorMode(0);
12  SetErrorMode( SEM_NOOPENFILEERRORBOX|SEM_FAILCRITICALERRORS );
13
14  TCHAR szStr[256];
15  DWORD dwNumBytes;
16  HANDLE hFile;
17
18  while (dwMask)
19  {
20    if (dwMask & 1)
21    {
22      hFile = CreateFile( szPathName, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING,
23                FILE_ATTRIBUTE_NORMAL|FILE_FLAG_SEQUENTIAL_SCAN, NULL );
24      if (hFile!=INVALID_HANDLE_VALUE)
25      {
26        dwNumBytes = 0;
27        if ( ReadFile( hFile, szStr, 32, &dwNumBytes, NULL ) )
28        {
29          CloseHandle( hFile );
30          if (strncmp( szStr, "Pac-Man: Adventures in Time", 27 ) == 0)
31          {
32            // found file, set the bit corresponding to its drive
33            dwLocation = 1<<uiBit;
34            break;
35          }
36        }
37
38        CloseHandle( hFile );
39      }
40    }
41
42    ++uiBit;
43    ++szPathName[0];
44    dwMask = dwMask >> 1;
45  };
46
47  // allow system to report errors again
48  SetErrorMode( uiOldErrorMode );
49
50  return dwLocation;
51}

This walks through the passed bitmask representing the CD drives and tries to open the PAC-MAN.DAT file on each.

One interesting thing of note is the calls to SetErrorMode()

Without this, Windows would pop up error message boxes (or worse, blue screens on some Windows 9x devices) if the game tries to access an empty or otherwise unreadable drive!

These silence and drive errors while we search, then re-enable afterwards.

Conclusion

Reviewing my own code all these years later is weird, I'm still proud of it, though I can certainly see quite a few improvements I would make these 😆

Thank you cpunch for your analysis and the opportunity to tell this story 😄