Reverse engineering an old Windows game for fun and compatibility
December 31, 2022A 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
) )
) )
)
) )
) )
) )
### Game window is minimized (6=SW_MINIMIZE)
) )
When trying to restore the minimized window I got:
### Game window is restored to 640x480...
) )
) )
) )
### ...but then immediately minimized again!
) )
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:
wParam
The new image depth of the display, in bits per pixel.
lParam
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: