Reverse engineering an old Windows game for fun and compatibility

A few months ago I decided to revisit The Last Express, one of the most unique adventure games of the 1990s. Since the version I have (from 1997) provided a Windows executable, I decided to run it using the Wayland driver for Wine. Eager to treat myself to a nostalgic journey, I ran the game and saw... nothing! I could hear the ominous menu music, but all I got was a minimized window which completely ignored my attempts at restoration. I tried the game with the Wine X11 driver and it worked as expected.

Disappointed, but also determined to get this working with the Wayland driver, I ran the game with Wine debug logging turned on. The strange minimization behavior led me to turn my attention to the SetWindowPos and ShowWindow calls, hoping that I would get some insight about what was affecting the window state. Here is what I got for the game start up, with some additional comments:

### Game window being created and resized to 640x480
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (0x0), flags 00000037
trace:win:show_window hwnd=0x1005e, cmd=5, was_visible 0
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (0x0), flags 00000043
trace:win:NtUserSetWindowPos hwnd 0x1005e, after 0xffffffff, 0,0 (1600x920), flags 00000070
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (0x0), flags 00000003
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (640x480), flags 00000050
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (0x0), flags 00000037
### Game window is minimized (6=SW_MINIMIZE)
trace:win:show_window hwnd=0x1005e, cmd=6, was_visible 1
trace:waylanddrv:WAYLAND_ShowWindow hwnd=0x1005e cmd=6
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), -32000,-32000 (160x24), flags 00008174

When trying to restore the minimized window I got:

### Game window is restored to 640x480...
trace:win:show_window hwnd=0x1005e, cmd=9, was_visible 1
trace:waylanddrv:WAYLAND_ShowWindow hwnd=0x1005e cmd=9
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (640x480), flags 00008120
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (640x480), flags 00000014
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), 0,0 (640x480), flags 00000050
### ...but then immediately minimized again!
trace:win:show_window hwnd=0x1005e, cmd=6, was_visible 1
trace:waylanddrv:WAYLAND_ShowWindow hwnd=0x1005e cmd=6
trace:win:NtUserSetWindowPos hwnd 0x1005e, after (nil), -32000,-32000 (160x24), flags 00008174

It seemed that some logic was forcing the window to always become minimized. After briefly exploring the possibility of the Wayland driver or Wine core doing something fancy, I reached the conclusion that the minimization logic lived in the application itself.

Why did the application minimize itself when running with the Wayland driver? A mystery befitting the theme of the game itself!

In order to gain more insights, I had to take a peek into the inner workings of the game. I loaded the _le.exe executable in Ghidra, analyzed it with the default options and I searched for symbols references to ShowWindow:


The first entry was for the location within the Import Address Table (IAT) which the dynamic linker will fill in with the address of the CWnd::ShowWindow function from MFC42.DLL.

The second entry was the CWnd::ShowWindow thunk function within _le.exe, which transfers control to the real CWnd::ShowWindow function by using the address from the IAT location described above:

004a8996 JMP dword ptr [->MFC42.DLL::CWnd::ShowWindow]

This import immediately provided a hint that the game was using Microsoft Foundation Classes, i.e., object-oriented wrappers around the Win32 API, and as such I should be on the lookout for standard object-oriented patterns.

The thunk function is what the application actually calls internally, and in the list above I could see several references to it. Alas, I checked the code at each reference location, but none was passing 6 (SW_MINIMIZE) as the show command. Could there be other references to the CWnd::ShowWindow thunk that Ghidra was missing?

I decided to get a second opinion from radare2:

$ radare2 -A _le.exe
[x] Analyze all flags starting with sym. and entry0 (aa)
[x] Analyze function calls (aac)
[x] Analyze len bytes of instructions for references (aar)
[x] Finding and parsing C++ vtables (avrr)
[x] Type matching analysis for all functions (aaft)
[x] Propagate noreturn information (aanr)
[x] Use -AA or aaaa to perform additional experimental analysis.
[0x004a8cf0]> axt 0x004a8996
(nofunc) 0x40fbe8 [CALL] call sub.MFC42.DLL_Ordinal_6215
(nofunc) 0x45a37e [CALL] call sub.MFC42.DLL_Ordinal_6215
fcn.0045a480 0x45a54a [CALL] call sub.MFC42.DLL_Ordinal_6215
(nofunc) 0x45af0e [CALL] call sub.MFC42.DLL_Ordinal_6215
(nofunc) 0x45afa7 [CALL] call sub.MFC42.DLL_Ordinal_6215
(nofunc) 0x45b018 [CALL] call sub.MFC42.DLL_Ordinal_6215
fcn.0045b940 0x45b9a4 [CALL] call sub.MFC42.DLL_Ordinal_6215
fcn.0045b940 0x45b9ea [CALL] call sub.MFC42.DLL_Ordinal_6215
fcn.0045ba20 0x45bb92 [CALL] call sub.MFC42.DLL_Ordinal_6215

There were indeed more references to the CWnd::ShowWindow thunk. I noted that the references Ghidra was missing didn't belong to any known function ((nofunc) in radare2), and that was the reason they weren't detected by default. The solution was to rerun Ghidra's analysis with the "Aggressive Instruction Finder" option turned on, which gave me the following references:


Note that the 0x45a37e reference was still missing from Ghidra. I was able to to add it by manually marking the region as code (in fact the relevant function seems to start at 0x45a340). In the end it didn't matter, though, since that particular invocation was using the 9 (SW_RESTORE) command, so was not relevant for our investigation.

From the updated reference list above there were two that involved a minimization command, at 0x45af0e and 0x45afa7. The function at 0x45ae10 which contained the 0x45af0e reference was decompiled by Ghidra as:


Although the high-level flow of this function was clear enough, I must say I didn't become more enlightened about the condition(s) that could lead to minimization. A quick look at some of the invoked functions indicated that a deeper investigation was required to understand how all this works, so I decided to move to the next function and revisit later, if needed.

The second reference 0x45afa7 was part of the of the 0x45af70 function:


This was more promising, with a very clear check affecting minimization at the start of the function! As I could see from the decompiler output, the first argument was effectively a CWnd *. The unconditional call to CWnd::OnDisplayChange at the end allowed me to reasonably (and ultimately correctly) assume that this function was in fact an override of the OnDisplayChange method for a custom class inheriting from CWnd, so I named this function LExpressCWnd::OnDisplayChange.

The documentation for CWnd::OnDisplayChange was (and still is) surprisingly scarce online, but in a Microsoft C/C++ changelog I found the following:

CWnd::OnDisplayChange changed to (UINT, int, int) instead of (WPARAM, LPARAM) so that the new ON_WM_DISPLAYCHANGE macro can be used in the message map.

Since the game predated these changes, I determined that param_1 and param_2 were really wparam and lparam of the corresponding WM_DISPLAYCHANGE message. From the WM_DISPLAYCHANGE documentation:

The new image depth of the display, in bits per pixel.
The low-order word specifies the horizontal resolution of the screen.
The high-order word specifies the vertical resolution of the screen.

The pieces of the puzzle finally started to fall into place! Taking into account the new information I updated the decompiled source like so:


There was a lot missing still, but the main takeway, in the highlighted part of the code above, was that if the display mode was changed to one that did not use 16 bits per pixel, the application unceremoniously minimized itself!

Indeed, in that older version of the Wayland driver for Wine we were choosing a 32-bit mode regardless of what the application specified. This worked for most DirectX games since Wine internally deals with the inconsistency. However, The Last Express explicitly checks the bits per pixel value sent with the WM_DISPLAYCHANGE message and our ploy is exposed!

I updated the Wayland driver to properly set the WM_DISPLAYCHANGE bits per pixel parameter, and verified that this fixed the minimization problem. I was finally able to continue my (train) trip down memory lane!

You can see The Last Express in action with the Wayland driver here.

Bonus material

Although this partial analysis was enough to allow me to resolve the task at hand, I remained curious about some of the details of display configuration and the unexplained aspects of the LExpressCWnd::OnDisplayChange function. To explore further, I tracked symbol references to the ChangeDisplaySettingsA function from USER32.DLL and came across two invocations in _le.exe. I will spare you the details, but one reference was from a function that I determined configured the display mode for the game (which I named LExpressCWnd::ConfigureDisplay at 0x45ba20), and the other from a function that restored the original mode (LExpressCwnd::RestoreDisplay at 0x45bbd0). One interesting point about these invocations was that the code was tracking the kind of display configuration operation the game was performing, by using fields in LExpressCWnd. For example, in LExpressCWnd::ConfigureDisplay there was the following decompiled snippet (after giving some more descriptive names):


The LExpressCWnd::configuring_display field and the corresponding LExpressCWnd::restoring_display used in LExpressCWnd::RestoreDisplay, were two of the mysterious fields used in LExpressCWnd::OnDisplayChange. The idea is that ChangeDisplaySettingsA sends the WM_DISPLAYCHANGE message synchronously, and thus LExpressCWnd::OnDisplayChange is called in the context of that invocation, reading the values of these fields.

With all this new information and a lot of additional exploration in the executable, I reached this more complete version of LExpressCWnd::OnDisplayChange:


Fortuitously, the discovery of the LExpressCWnd::ConfigureDisplay/RestoreDisplay functions also helped to shed some light on the mysterious function at 0x45ae10 which we encountered earlier in this post. My best guess about that function was that it handles the (de)activation of the game window, so I named it LExpressCWnd::OnActivate. On activation it increases the game thread priority and configures the display mode (if in fullscreen mode). On deactivation it sets the game thread priority to normal, restores the display mode and minimizes the window. My final decompilation looks like: