seri.tools

Why Castrol Honda Superbike crashes on (most) modern systems

A friend cleaned up and gave me a copy of a game I've not heard about before: Castrol Honda Superbike World Champions, a motorbike racing game for PC, released 1998 by Interactive Entertainment Ltd. and Midas Interactive Entertainment.

Given the age of the game (and looking at the system requirements) it's clear that the game comes from the tricky era of early 3D-accelerated PC gaming. For context, my copy of the game helpfully asks to install DirectX 5.

Before Windows was known for cramming AI and account requirements into every single corner of the system, no matter how unnecessary, it was known for its excellent backwards compatibility with older software. Generally, unless there are genuine bugs (and sometimes even despite them), Windows tries its hardest to run old applications correctly.

Pushing my luck and trying to run it on my Windows 7 machine, however, resulted in either a getting stuck on a black screen, or a crash, seemingly at random:

Let's go back in time and see how far we need to go to get it running: Installing and running it on my Windows 98 and Windows XP machines was as uneventful, and the game works just fine1, including with 3D acceleration. Glorious 1024x768x16:

Debugging the issue #

Debugging is more fun than playing, so let's get started! :^)

I pulled over the installation directory to my main machine and ran Detect It Easy to see what we can learn about the executable:

Linker: Microsoft Linker(5.10) and Compiler: Microsoft Visual C/C++(...)[libcmtd] are the interesting bits here. Notice how it's libcmtd, not libcmt? The binary is linked against the static debug version of VC5's runtime. The debug runtime has heaps of extra checks and logging, which might help later.

Let's attach a debugger and see what's going on. Given that the game crashes very early on (before the credits intro screen), I hoped to see something right away. The cases where the game got stuck in a loop seemed to actually get stuck in some Windows API call stack.

Anyway, the cases where it crashed gave a clearer starting point:

The game seems to be stuck after a call to DirectInput's DirectInputCreateEx function. At this point I started to do some static analysis of the functions leading to this call. While doing that I noticed that the game seems to have quite extensive logging, anything from game initialization to memory allocations.

If you're interested in all the logs, here are the config settings to enable them all:

  • In Config.dat, switch ErrorLog, FileLog, MallocLog from off to on.
    • These are the "normal" log files the game produces.
    • ErrorLog produces error.log, which is the general log file.
    • FileLog produces files.log, tracking all opened files and their access modes.
    • MallocLog produces malloc.log, tracking all memory allocations and frees. The devs even kept descriptions for every allocation site!
  • Set an environment variable named errorfile to any file name (not path). The game will write logs to that file in the game directory.
    • You might also need to create an empty *.c file in the game directory.
    • Just gives a bit of extra logging.

Bonus: add a setting named windowed=true to the config file to force windowed mode; only works correctly in 16-bit mode (garbled graphics in True Color).

After enabling all the logging, I ran the game a few more times, and noticed that the last log messages in error.log before the crash were these:

0> Instance : Mouse
0> Product : Mouse
1> Instance : Keyboard
1> Product : Keyboard
2> Instance : Gaming Mouse G502
2> Product : Gaming Mouse G502
3> Instance : Gaming Mouse G502
3> Product : Gaming Mouse G502
4> Instance : Gaming Mouse G502
4> Product : Gaming Mouse G502
5> Instance : Gaming Mouse G502
5> Product : Gaming Mouse G502
6> Instance : USB Keyboard
6> Product : USB Keyboard
7> Instance : USB Keyboard
7> Product : USB Keyboard
8> Instance : LED Controller
8> Product : LED Controller

Great, the game is enumerating input devices— uh, why is there an "LED Controller" device? The motherboard in my Windows 7 machine has a built-in LED controller, so that checks out. Maybe the detection isn't working properly, and the game is trying to use it as an input device?

After disabling the LED controller in Device Manager, the game started up just fine, consistently! So far, so good. Of course I wanted to know what was actually going wrong, though, so let's see where these messages are printed.

Side quest: CD check #

The game seemed to close without any notice if I forgot to insert the game disc. A quick trace showed that the GibbonPosture setting in f1.cfg is used to point to the disc drive from which the game was installed. The only check seems to be that the path redist\dsetup.dll exists on the disc. Copying the redist folder to the installation directory and changing the setting to GibbonPosture=.\ seems to work just fine. :)

The bug #

Finding the Instance : and Product : log messages in the binary was easy enough. They are referenced in only one function, which is a DIEnumDevicesCallback callback function that is provided to IDirectInput::EnumDevices (Microsoft has only kept the documentation for the DX8 version of EnumDevices left online, but it's close enough).

This is the pseudocode of the call and the callback, and the relevant data structure:

struct DinputDeviceData
{
  char instance_name[128];
  char product_name[128];
  DWORD dwDevType;
  GUID guid;
};

// ...

BOOL __stdcall dinput_enumdevices_callback(LPCDIDEVICEINSTANCEA lpDevice, LPVOID pvRef)
{
    int index = g_dinput_device_index;
    g_direct_input_devices[index].guid = lpDevice->guidInstance;
    strcpy(g_direct_input_devices[index].instance_name, lpDevice->tszInstanceName);
    strcpy(g_direct_input_devices[index].product_name, lpDevice->tszProductName);
    g_direct_input_devices[index].dwDevType = lpDevice->dwDevType;

    log_line("%d> Instance : %s\n", index, lpDevice->tszInstanceName);
    log_line("%d> Product : %s\n", index, lpDevice->tszProductName);

    if ( LOBYTE(g_direct_input_devices[index].dwDevType) == DIDEVTYPE_JOYSTICK )
    {
        int joystick_index = g_joystick_index;
        g_joystick_info[joystick_index].dinput_device_index = index;
        g_joystick_info[joystick_index].field_4 = 0;
        g_joystick_info[joystick_index].field_8 = 0;
        g_joystick_info[joystick_index].field_38 = 0;
        g_joystick_info[joystick_index].field_1 = 0;
        g_joystick_index = joystick_index + 1;
    }
    g_dinput_device_index = index + 1;

    return DIENUM_CONTINUE;
}

// ...

g_dinput_create_hresult = DirectInputCreateA(hInstance, 0x500u, &g_dinput_instance, 0);
g_dinput_device_index = 0;
g_joystick_index = 0;
g_dinput_instance->lpVtbl->EnumDevices(
    g_dinput_instance, 0, dinput_enumdevices_callback, 0, DIEDFL_ATTACHEDONLY);

So, for each enumerated device, the game stores some general information about it in the global array g_direct_input_devices. Then, if the device is a joystick (generally, a game controller), it also adds it to g_joystick_info.

Can you guess the bug yet? :) If not, here's the declaration of the global arrays:

DinputDeviceData g_direct_input_devices[8];
// ...
JoystickInfo g_joystick_info[8];

There's only space for eight DirectInput devices in the array! 8> Instance : LED Controller was the ninth one, overwriting lots of important other data in the process, including timer handles and the actual DirectInput instance pointer.

But it gets worse: The game uses DirectInput for game controllers only. Copying the device info out of lpDevice is entirely pointless for other types of devices. Just moving the DIDEVTYPE_JOYSTICK check up would have hidden the bug for basically all setups, since you'd have to have more than 8 game controllers connected for the game to write out of bounds.

Actually, there would've been an even simpler workaround: EnumDevices allows passing a DIDEVTYPE as a filter:

g_dinput_instance->lpVtbl->EnumDevices(
    g_dinput_instance, DIDEVTYPE_JOYSTICK, dinput_enumdevices_callback, 0, DIEDFL_ATTACHEDONLY);
                    // ^^^^^^^^^^^^^^^^^^

This would make DirectInput call the callback for game controllers only. Without it, all devices, whether they are keyboards, mice, or actually any HID devices, are enumerated. (I've checked the DirectX 5 SDK docs, and even there it mentions the HID device support.) This includes the vendor-defined devices of my mouse and its emulated keyboard (for macros), and of course the motherboard's LED controller.

The moral of the story? Always check your bounds, kids! You'll never know if some weirdo comes along and plugs in a dozen game controllers to their PC. :^)

The fix #

Over on GitHub I've pushed a minimal patch as a classic DLL shim. With the provided dinput.dll in the game directory, the game will load that instead of the system one. DirectInput has only one relevant exported function that we need to shim: DirectInputCreateA. The rest of the API is implemented via COM interfaces, for which we can modify the respective vtables as needed.

I've implemented two fixes in the shim:

  1. Inject the DIDEVTYPE_JOYSTICK filter in the call to EnumDevices to only return joysticks/game controllers.
  2. Cancel enumeration once 8 joysticks have been found.

For fun, I've also tried to minimize the size of the shim DLL -- the final binary weighs in at 2 KiB.

These are the reasonable settings I changed:

  • Compile with opt-level = "z" to optimize for minimum size. (though the code is so low-level that it's effectively the same as opt-level = 3)
  • #[no_std] to avoid linking the Rust standard library.
  • codegen-units = 1 and lto = true to enable whole-program optimization.
  • panic = "immediate-abort" to remove all unnecessary panic handling code; an unwrap will immediately abort the process.

And these are the cursed ones:

  • /NODEFAULTLIB to not link against any MSVC runtime library; added my own minimal DllMain.
  • /FORCE:UNRESOLVED to ignore the missing symbols for _aullrem, _aulldiv, and _fltused. We aren't using any of these, but the LLVM target still seems to insist on linking them in.
  • /FILEALIGN:512 to force the linker to use the minimum supported PE section alignment in the file.
  • /MERGE:.rdata=.text merges the read-only data section into the code section.
  • Prevent zero-initialization of the system directory path by using MaybeUninit.
  • Storing globals in static mut just like the original game does. Since the fix is specific to this game I can do these "global" assumptions here :^)
    • Ensure all globals are zero-initialized so the .data section is 0 bytes in the binary.
  • .unwrap_unchecked() to avoid any extra branches where they aren't needed.
  • /DEBUG:NONE to not generate debug information, and not store a .pdb path in the binary.

Furthermore, I switched to rust-lld.exe as the linker, as it's fine with setting /SUBSYSTEM:WINDOWS,4.0" and /OSVERSION:4.0 without complaining. :) Since there is no linked runtime code at all, the resulting binary should work on any 32-bit Windows version, even without Rust9x. I've tested it on Windows 7 and Windows 98 SE.

Feel free to grab the compiled DLL from the releases page.

  1. Except for True Color rendering, which seems to be broken, no matter the system, graphics card, or game version? Investigating that is left as a mystery for another day.