First of all, create a folder to store all the stuff of this project. Personally, I have my folder structure set up in $HOME/earthsiege2 this way:
images: This folder contains the three ISOs of EarthSiege 2 that I uploaded to the Internet Archive
tools: This folder contains the installation media of BC++, TASM, the Windows update MSUs etc.
ghidra: This folder will be the "Project Directory" in Ghidra and has two sub-folders:
binary: this contains the three EarthSiege binaries ES.EXE, DBSIM.EXE and VSHELL.EXE, copied from the VM folder C:\SIERRA\ES2.
runtime: this contains the Borland C++ Runtime files BWCC32.DLL (Borland Windows Custom Control), CW3220.DLL (the C runtime of Borland C++, aka its equivalent to Linux's libc.so), SOS9503.DLL and SOSLIBS3.DLL (these seem to be sound related)
Then, open Ghidra and create a new non-shared project. I named mine "earthsiege2" for simplicity. (TODO: Figure out how to set up a Ghidra server...)
Create the binary and runtime folders in the main view, drag and drop the files from Finder/Explorer into their respective Ghidra folders, and run the import.
For each binary you will load, it is highly recommended to load my Ghidra Data Type library (bcpp3220.gdt) in the Data Type Manager. It contains everything I could wrestle out of the BC++ RTL source code and header files that was useful in my efforts, but it is nowhere near complete (the header files are extremely complex messes of #ifdef's and so I only modeled by hand what I needed).
Also, for Borland the default calling convention is __cdecl (according to SOURCE/RTL/RTLINC/COMMON32/RULES.ASI), so go to Edit => => Properties => Decompiler and set "Prototype Evaluation" to __cdecl.
The first binary we will be having a look at is ES.EXE.
ES.EXE is the launcher of the game - it launches either VSHELL.EXE (the game's menu) or DBSIM.EXE (the actual 3D engine) and uses the process return codes to determine what it should do.
In the beginning, we do not want to run any analyzers - we first need to account for a bug in the Borland compiler which creates the Header Section too small, leading IDA to barf at loading sections. Expand the Headers block down to 0x600:
Then, in the code view go to 0x4002e8, and set Type of this address to PE/IMAGE_SECTION_HEADER. Note that the binary seems to have been trimmed down - the length alone of the .debug section should be ~31 kB according to the header, but the total file length is only 11 kB.
I have no idea what the compiler has done there, all I know is that this is not just happening to the .debug section, it will happen to whatever section is the last. Just look at the bug I filed at Ghidra... anyway, we don't need any debug information for this small binary.
Now, run the default set of analyzers (except the MSVC/GNU demanglers which are useless).
What we also need now is an editor with these files opened from the Borland C++ source code:
BC5/SOURCE/RTL/SOURCE/STARTUP/COMMON32/STARTUP.C
BC5/SOURCE/RTL/SOURCE/STARTUP/WIN32/C0NT.ASM
BC5/SOURCE/RTL/RTLINC/COMMON32/_STARTUP.H
Since the first thing we are going to do is copy over the global data structures and function names of the Borland C++ runtime library, we need these as our information sources.
I have done the tedious work of creating a Ghidra type archive that corresponds with BC++ 5 so you don't need to manually create structures... so let's get started.
The first thing in the DATA segment is the copyright string: "Borland C++ - Copyright 199X Borland Intl.", you can see it in C0NT.ASM. Use this to verify if you have the correct version of Borland C++ - this string varies between releases!
Directly after this string, look for a 4-byte alignment... this will be the MODULE_DATA structure which the startup code in the entry point function uses to learn about itself. One of the auto-analyzers has helpfully already decoded it as a pointer... clear it so we can then set the type to MODULE_DATA:
Before cleaning...
... and after cleaning. Looks way better!
Now, head over to the entry function - by convention, it will be at 0x00401000. Open C0NT.ASM, head to __acrtused and apply all the labels you see from there to the respective DAT_xxx labels that Ghidra created. While you're at it, you can set its signature to "DWORD __acrtused(void)" and its calling convention to __stdcall, per Microsoft convention.
Done that, now head back to the MODULE_DATA struct, and apply the appropriate labels and types to the stuff that is pointed in the structure:
For the init_start/exit_start, first set the type of the first byte to INIT, then create an appropriately-sized array (in ES.EXE, 2 each). You can re-name them to _initX for now - for those that are part of user-land code, we will create actual labels when we stumble upon them later again.
Function signatures for WinMain, _matherr and _matherrl are in the type library, create functions at the locations and apply the type signatures
Up next are global variables, some of which are set at compile time and some at run time. Unfortunately you will have to do these all by hand, and the list depends on your exact version of Borland C++. Look at C0NT.ASM and follow it through. In the case of ES.EXE, the sequence is
bool ___isDLL
DWORD __TlsIndex
DWORD __TlsIndex4
void* save_stacktop
void* save_stacktop_plus_4 (in the original C0NT.ASM, the usage in __acrtused was actually written as [stacktop+4], but turned into an absolute address by the assembler, *sigh*)
bool ___isGUI
HMODULE _hInstance
Everything else that follows is some padding, then global variables of the userland code.
So, we move on... again back to __acrtused, to begin dealing with the exception handler. As Borland has graciously excluded its source from the 4.x/5.x CD-ROMs of Borland C++, we need to look at the C++ Builder source for information about this.
Directly below __acrtused lives __GetExceptDLLinfo, which (like __GetExceptDLLinfoInternal, which it jumps to) has a signature of void __cdecl _GetExceptDLLinfoInternal(excDLLinfoPtr infoPtr).