(This post is part of a series on the subject of my hobby project, which is recreating the C source code for the 1989 game F-15 Strike Eagle II by reverse engineering the original binaries.)

As things stand today, looking around the first game executable which I’m reconstructing (START.EXE), it seems like there’s more than I don’t know about the game then I do know, especially when looking at the contents of the data segment. Some data is obvious (strings), some I have figured out by examining routines and figuring out what the data referenced in those routines are for, but the vast majority is a bunch of meaningless values (or no values for the uninitialized data/BSS, but we’ll get to that later). Today, I’m trying to do something about it.

A significant chunk of the game’s code is made of the standard C library subroutines, which I have identified a long time ago using IDA’s feature of routine signatures. They begin at offset 0x5542 with the start routine which is the startup (aka crt0) code that serves as the entrypoint to the program and ends up calling main(). Other libc subroutines follow until an unnamed subroutine (not public so the name likely got stripped, but it’s still recognized as belonging to libc by IDA) ends at offset 0x6a5b, which is also the end of the first code segment (there are two).

Knowing about these subroutines means less work for me because I don’t need to analyze them to know what they are doing, or reimplement them. They also serve as an useful anchor for the actual game routines - if a game routine calls strcpy(), that means I can understand what it’s doing on a higher lever, and also infer something about the data that’s used as arguments for that function.

But the libc functions also come with data of their own (initialized and uninitialized) that is merged with the game data to form the complete executable, with no apparent way to tell it apart. If I knew where it was, I could also ignore it while analyzing the disassembly, and also I would know what to skip when trying to reconstruct the contents of the data segment. If I ever want to get an identically reconstructed executable, I need to know where it begins and ends, so that when the final result is linked with libc again, I would not end up with duplicate data. So let’s try to figure it out.

The inspiration for this came from reading this blog where the author uses some clever tricks to undo linking with the help of Ghidra and a custom extension, the end result being functional object files which can be linked with custom code again to form a patched game executable. That way it might be possible to excise ancient APIs, provide modern replacements, instrumentation etc., otherwise using the game logic as is. But that approach is used with MIPS code for a PSX game, and it’s not clear whether it could be adopted to x86 code, especially in real mode. I won’t be doing anything as ambitious, but I will try to figure out where exactly the linker put the libc data.

The plan is as follows:

  1. Create a threadbare program with some sentinel data values but without anything from libc, inspect its layout
  2. Add the startup code from libc, record any change in layout
  3. Add the exact same subset of libc functions that the game uses, see what changed again
  4. Assuming that the same subset of functions results in the same order of code and data in the executable, cross-reference the information obtained from 1-3 with the layout of the game’s data segment and try to spot the libc data

First step, an exe with no libc

I’m starting with compiling a trivial C program with no libc at all. Such a program is useless, since the C main() function will not work unless the startup code ran first to set things up - C might be a simple and low-level language, but it still needs minimal run-time support. Luckily, I don’t need the sample programs I’m building today to be functional, just so long as they link.

// hello.c
int _acrtused; // trick linker into thinking startup code is already there

/* initialized data */
const char foobar[] = "hello_c_data";
int barfoo = 0xabcd;
const char foobaz[] = "something_else";
/* uninitialized (BSS) data */
int x;
int y;

int main() {
    x = 1;
    y = 2;
    return 0;
}

I encountered the __acrtused symbol (remember, the C compiler adds one underscore to all names) before, it’s a tag that the linker uses to determine that the startup code is already present. What it is doesn’t really matter as long as it’s there. So let’s build this. I’m using the /NOD[EFAULT] option to the linker to prevent linking with libc, which would happen by default, because the object file hello.obj contains an entry for a default library to link with after it’s been compiled. The remaining /M /I cause the linker to generate a map file and verbose output.

ninja@dell:eaglestrike$ make hello
cl /Gs /c /Foe:\hello.obj hello.c
link /M /I /NOD  hello.obj,d:\hello.exe,,;

Microsoft (R) Overlay Linker  Version 3.65
Copyright (C) Microsoft Corp 1983-1988.  All rights reserved.

**** PASS ONE ****
HELLO.OBJ(hello.c)
**** LIBRARY SEARCH ****
**** ASSIGN ADDRESSES ****
LINK : warning L4021: no stack segment
  1 segment "_TEXT" class "CODE" length 10H bytes
  2 segment "_DATA" class "DATA" length 12H bytes
  3 segment "CONST" class "CONST" length 0H bytes
  4 segment "_BSS" class "BSS" length 0H bytes
  5 segment "c_common" class "BSS" length 6H bytes
**** PRINT MAP ****
**** PASS TWO ****
HELLO.OBJ(hello.c)
**** WRITING EXECUTABLE ****
ls -l build-f15-se2/hello.exe
-rw-r--r-- 1 ninja ninja 559 Dec 23  2023 build-f15-se2/hello.exe

This executable file clocks in at 559 bytes, of which 512 is the MZ exe header. As expected, it’s pretty lean, so let’s look at it in IDA:

seg000:0000 seg000          segment byte public 'CODE' use16
seg000:0000                 assume cs:seg000
seg000:0000                 assume es:nothing, ss:seg000, ds:nothing, fs:nothing, gs:nothing
seg000:0000
seg000:0000 ; =============== S U B R O U T I N E =======================================
seg000:0000                 public start
seg000:0000 start           proc near
seg000:0000                 mov     word ptr ds:22h, 1 ; x = 1
seg000:0006                 mov     word ptr ds:24h, 2 ; y = 2
seg000:000C                 sub     ax, ax
seg000:000E                 retn
seg000:000E start           endp
seg000:000E ; ---------------------------------------------------------------------------
seg000:000F                 nop
seg000:0010 aHello_c_data   db 'hello_c_data',0   ; foobar
seg000:001D                 db 0                  ; alignment?
seg000:001E                 dw 0ABCDh             ; barfoo
seg000:0020 aSomething_else db 'something_else',0 ; foobaz
seg000:0020 seg000          ends
seg000:0020
seg000:0020
seg000:0020                 end start

No major surprises there, there is no startup code, so the data segment is not set up properly. IDA was unable to find it, and it displays the executable as being all in one segment. The foobar string is the first data item placed by the linker at offset 0x10 (which would be DS:0 in a functional executable). Then comes the initialized barfoo value of 0xabcd, preceeded by a single null byte, probably for purpose of alignment (it disappears when I make foobar one character shorter). Last as expected is the string foobaz, and the uninitialized values were placed at DS:0x22 and DS:0x24 as can be seen from the code in start (which is actually main()). Those might be confused as colliding with “something_else” at 0x20, but keep in mind that the data segment’s origin is actually 0x10 in this display, so they are well past the string.

The only minor surprise is the alignment. I confirmed that the null byte is not part of the object file data by using the dmpobj utility from the OpenWatcom compiler:

ninja@dell:eaglestrike$ dmpobj build-f15-se2/hello.obj
[...]
LEDATA(a0) recnum:19, offset:0000011eh, len:0011h, chksum:7eh(7e)
    Seg index:2 offset:00000000h
    00000000 68|65 6c|6c 6f|5f 63|5f 64|61 74|61 00          <hello_c_data.> ✅ foobar and the null terminator

LEDATA(a0) recnum:20, offset:00000132h, len:0015h, chksum:edh(ed)
    Seg index:2 offset:0000000eh
    0000000e cd|ab 73|6f 6d|65 74|68 69|6e 67|5f 65|6c 73|65 <..something_else> ✅ barfoo (no preceeding null byte) and foobaz
    0000001e 00                                              <.>

A thing to note for the future is that null bytes that are not part of the original data may be apparently placed in the result by the linker.

Adding in the startup code

Looking good, wonder what will happen when I link in the actual libc startup code? For that I need to remove the __acrtused tag and either get rid of the /NOD option, or spell the name of the appropriate library file (slibce.lib for small memory model and software emulated floating point) on the linker command line - I choose the latter.

ninja@dell:eaglestrike$ make hello
cl /Gs /c /Foe:\hello.obj hello.c
link /M /I /NOD  hello.obj,d:\hello.exe,,slibce.lib;
ls -l build-f15-se2/hello.exe
-rw-r--r-- 1 ninja ninja 2325 Dec 24  2023 build-f15-se2/hello.exe

That makes for a hefty increase to 2325 bytes. Let’s look inside.

seg000:0000 seg000          segment byte public 'CODE' use16
seg000:0000                 assume cs:seg000
seg000:0000                 assume es:nothing, ss:nothing, ds:dseg, fs:nothing, gs:nothing
seg000:0000                 db 10h dup(0)
seg000:0010 ; =============== S U B R O U T I N E =======================================
seg000:0010 ; int __cdecl main(int argc, const char **argv, const char **envp)
seg000:0010 _main           proc near               ; CODE XREF: start+8D
seg000:0010                 mov     word_10720, 1 ; x = 1
seg000:0016                 mov     word_10722, 2 ; y = 2
seg000:001C                 sub     ax, ax
seg000:001E                 retn
seg000:001E _main           endp
seg000:001E ; ---------------------------------------------------------------------------
seg000:001F                 align 2
seg000:0020                 assume ss:seg002, ds:nothing
seg000:0020 ; [000000B2 BYTES: COLLAPSED FUNCTION start. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:00D2 ; [000000C4 BYTES: COLLAPSED FUNCTION __cinit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0196 ; [00000017 BYTES: COLLAPSED FUNCTION _exit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:01AD ; [00000045 BYTES: COLLAPSED FUNCTION __exit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:01F2 ; [0000002D BYTES: COLLAPSED FUNCTION __ctermsub. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:021F ; [0000000F BYTES: COLLAPSED FUNCTION sub_1021F. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:022E ; [00000013 BYTES: COLLAPSED FUNCTION sub_1022E. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0241                 align 2
seg000:0242 ; [00000020 BYTES: COLLAPSED FUNCTION __FF_MSGBANNER. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0262 ; ---------------------------------------------------------------------------
seg000:0262 ; [00000006 BYTES: COLLAPSED CHUNK OF FUNCTION __cinit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0268 ; [00000022 BYTES: COLLAPSED FUNCTION __nullcheck. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:028A ; [0000018E BYTES: COLLAPSED FUNCTION __setargv. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0418 ; [0000006E BYTES: COLLAPSED FUNCTION __setenvp. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0486 ; [0000002B BYTES: COLLAPSED FUNCTION __NMSG_TEXT. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:04B1 ; [00000029 BYTES: COLLAPSED FUNCTION __NMSG_WRITE. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:04DA ; [00000042 BYTES: COLLAPSED FUNCTION __myalloc. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:051C                 align 8
seg000:051C seg000          ends
dseg:0000 ; ===========================================================================
dseg:0000 ; Segment type: Pure data
dseg:0000 dseg            segment para public 'DATA' use16 ; the interesting part is the layout of the data segment
dseg:0000                 assume cs:dseg
dseg:0000                 db    0 ; two null bytes
dseg:0001                 db    0
dseg:0002 word_10522      dw 0                    ; DATA XREF: start+50 
dseg:0004                 db 0
dseg:0005                 db    0
dseg:0006                 db    0
dseg:0007                 db    0
dseg:0008 aMsRunTimeLibra db "MS Run-Time Library - Copyright (c) 1988, Microsoft Corp" ; Microsoft libc watermark
dseg:0040                 db  11h
dseg:0041                 db    0               ; --- my data starts here
dseg:0042 aHello_c_data   db 'hello_c_data',0   ; foobar
dseg:004F                 db    0               ; alignment
dseg:0050                 dw 0ABCDh             ; barfoo
dseg:0052 aSomething_else db 'something_else',0 ; foobaz
dseg:0061                 db    0               ; --- alignment, more data from libc follows
dseg:0062 word_10582      dw 0                    ; DATA XREF: start+4A 
dseg:0062                                         ; __myalloc+8
dseg:0064 word_10584      dw 0                    ; DATA XREF: start+3E
dseg:0066 off_10586       dw offset __exit        ; DATA XREF: start+9C
dseg:0066                                         ; start+AE
dseg:0068 word_10588      dw 0                    ; DATA XREF: start+39
dseg:0068                                         ; __myalloc+2
dseg:006A                 dw seg dseg
dseg:006C                 db 4Ch dup(   0)
dseg:006C                 db  68h
dseg:006C                 db    0
dseg:006C                 db  3Bh
dseg:006C                 db  43h
[...]
dseg:01CF aR6001NullPoint db 'R6001',0Dh,0Ah
dseg:01CF                 db '- null pointer assignment',0Dh,0Ah,0
dseg:01F2                 db 0FFh
dseg:01F3                 db 0FFh
dseg:01F4                 db 0FFh            ; 3x 0FFh, libc initialized data end marker?
dseg:01F5                 db 0Bh dup (?)     ; 11 bytes of uninitialized data, 
                                             ; presumably from libc startup code,
                                             ; probably some alignment too, the next offset is too neat
dseg:0200 word_10720      dw ?               ; x
dseg:0202 word_10722      dw ?               ; y
dseg:0204                 align 10h
dseg:0204 dseg            ends

15 routines (and a “chunk” which in IDA speak is a disconnected part of a routine) made it into the executable. The unnamed (sub_...) ones are also recognized as part of libc by IDA, they are referenced by the named ones, just lost their names to stripping.

The real meat of this part of the experiment comes from the contents of the data segment. It opens with two null bytes, then a word value that’s referenced from start, that is the crt0 code. More null bytes follow, then the MSC library watermark which I know from the game, followed by 0x11, 0x0… and my code’s data begins. After it ends, more libc data continues. The initialized libc data ends with some error strings, and an apparent termination marker of 3 times 0xff. Following that are 11 (0xb) bytes of unitialized data before the two uninitialized variables of my own, and the data segment concludes.

Adding the remaining functions from libc

Here’s where it gets even more interesting. I look over the 63 items identified as originating from libc by IDA in the game’s executable, 5 of which are chunks. Out of the remaining ones, the __-prefixed ones are internal routines, which were not likely referenced directly, but were pulled in as dependencies. That leaves us with a small subset of actual libc functions that the game uses. I don’t really care about their signatures, and neither does the linker, so it will suffice to declare them as whatever and attempt to call them from my main().

// hello.c

// the signatures are not important for linking, only the name matters
void exit();
void getch();
void fclose();
void fopen();
void fread();
void fwrite();
void lseek();
void strcmp();
void getche();
void movedata();
void inp();
void putch();
void abs();
void srand();
void rand();

// These are probably the emulated floating point operations, originating from
// actual float arithmetic operations in the game code. I can't be bothered to
// recreate them right now, so I'll just call them directly.
void _aNldiv();
void _aNlmul();
void _aNlrem();
void _aNNaldiv();

int main() {
    rand();
    fclose();
    fopen();
    fread();
    fwrite();
    lseek();
    strcmp();
    getche();
    getch();
    movedata();
    inp();
    putch();
    abs();
    srand();

    _aNldiv();
    _aNlmul();
    _aNlrem();
    _aNNaldiv();

    x = 1;
    y = 2;
    return 0;
}

This yields an executable 7707 bytes long. I look inside again and compare the list of routines placed in the executable by the linker, but something is wrong. Routines were pulled in which are not present in the game, like itoa(). I check to see where it is used in my experimental executable:

seg000:05BC ; int __cdecl fclose(FILE *)
seg000:05BC _fclose         proc near               ; CODE XREF: _main+3p
[...]
seg000:0653                 call    _itoa
seg000:0656                 add     sp, 6
seg000:0659                 lea     ax, [bp+var_E]
seg000:065C                 push    ax              ; char *
seg000:065D                 call    _remove
seg000:0660                 add     sp, 2
seg000:0663                 or      ax, ax
seg000:0665                 jz      short loc_1066A
seg000:0667                 mov     di, 0FFFFh
seg000:066A loc_1066A:
seg000:066A                 mov     byte ptr [si+6], 0
seg000:066E                 mov     ax, di
seg000:0670                 pop     si
seg000:0671                 pop     di
seg000:0672                 mov     sp, bp
seg000:0674                 pop     bp
seg000:0675                 retn
seg000:0675 _fclose         endp

It’s used in fclose(), also a libc function, so it seems like it tagged along as a dependency. Looking at the same location within fclose() inside the game, it looks like it’s calling a different routine that’s not part of libc. Apparently, Microprose provided overrides of standard library functions. It’s probably a vestige of the codebase’s roots in older versions of the MSC compiler, where some of these functions might have not been available, so the devs rolled their own and forgot to remove them when switching to a newer compiler. Or maybe they figured they could do better? In the end, it seems like the following library functions were replaced:

void itoa() {}
void _setargv() {}
void _setenvp() {}
void strcat() {}
void strcpy() {}
void memcpy() {}

Trying to link this however surprisingly fails:

link /M /I /NOD  hello.obj,d:\hello.exe,,slibce.lib;

Microsoft (R) Overlay Linker  Version 3.65
Copyright (C) Microsoft Corp 1983-1988.  All rights reserved.

**** PASS ONE ****
HELLO.OBJ(hello.c)
**** LIBRARY SEARCH ****
C:\msc510\lib\SLIBCE.LIB(dos\crt0.asm)
C:\msc510\lib\SLIBCE.LIB(dos\crt0dat.asm)
C:\msc510\lib\SLIBCE.LIB(dos\crt0msg.asm)
C:\msc510\lib\SLIBCE.LIB(crt0fp.asm)
C:\msc510\lib\SLIBCE.LIB(chkstk.asm)
C:\msc510\lib\SLIBCE.LIB(chksum.asm)
C:\msc510\lib\SLIBCE.LIB(dos\stdargv.asm)
C:\msc510\lib\SLIBCE.LIB(dos\stdargv.asm) : error L2044: __setargv : symbol multiply defined, use /NOE
[...]

That is very strange, why is the symbol multiply defined? I would expect the linker to only pull in functions from the library which could not be resolved locally, yet it seems to be grabbing the library version while it has my replacement readily available, and the two clash? Using /NOE does indeed let it link successfully, but it has unwanted side effects in that the order of libc routines within the executable changes. Before overriding the libc functions and adding /NOE, both my hello.exe and the game executable have libc routines arranged in this order (of course, the offsets differ):

seg000:0056 ; [000000B2 BYTES: COLLAPSED FUNCTION start. PRESS CTRL-NUMPAD+ TO EXPAND] 🟡 the startup (entrypoint) routine
seg000:0108 ; [000000C4 BYTES: COLLAPSED FUNCTION __cinit. PRESS CTRL-NUMPAD+ TO EXPAND] 
seg000:01CC ; [00000017 BYTES: COLLAPSED FUNCTION _exit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:01E3 ; [00000045 BYTES: COLLAPSED FUNCTION __exit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0228 ; [0000002D BYTES: COLLAPSED FUNCTION __ctermsub. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0255 ; [0000000F BYTES: COLLAPSED FUNCTION sub_10255. PRESS CTRL-NUMPAD+ TO EXPAND] 🟡 two unnamed unknown functions recognized as part of libc by IDA
seg000:0264 ; [00000013 BYTES: COLLAPSED FUNCTION sub_10264. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0277                 align 2
seg000:0278 ; [00000020 BYTES: COLLAPSED FUNCTION __FF_MSGBANNER. PRESS CTRL-NUMPAD+ TO EXPAND]
[...]

After I do the override and add /NOE, it changes to the following:

seg000:0062 ; [0000016C BYTES: COLLAPSED FUNCTION start. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:01CE ; [00000027 BYTES: COLLAPSED FUNCTION _fopen. PRESS CTRL-NUMPAD+ TO EXPAND] 🔴 functions that I called get placed after `start`
seg000:01F5                 align 2
seg000:01F6 ; [000001E6 BYTES: COLLAPSED FUNCTION _fread. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:03DC ; [0000013C BYTES: COLLAPSED FUNCTION _fwrite. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0518 ; [0000007A BYTES: COLLAPSED FUNCTION _lseek. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0592 ; [0000002B BYTES: COLLAPSED FUNCTION _strcmp. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:05BD                 align 2
seg000:05BE ; [00000004 BYTES: COLLAPSED FUNCTION _getche. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:05C2 ; [00000017 BYTES: COLLAPSED FUNCTION _getch. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:05D9                 align 2
seg000:05DA ; [0000001E BYTES: COLLAPSED FUNCTION _movedata. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:05F8 ; [0000000D BYTES: COLLAPSED FUNCTION _inp. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0605                 align 2
seg000:0606 ; [0000000F BYTES: COLLAPSED FUNCTION _putch. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0615                 align 2
seg000:0616 ; [00000015 BYTES: COLLAPSED FUNCTION _abs. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:062B                 align 2
seg000:062C ; [00000011 BYTES: COLLAPSED FUNCTION _srand. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:063D                 align 2
seg000:063E ; [00000026 BYTES: COLLAPSED FUNCTION _rand. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0664 ; [0000009C BYTES: COLLAPSED FUNCTION __aNldiv. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0700 ; [00000034 BYTES: COLLAPSED FUNCTION __aNlmul. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0734 ; [000000A2 BYTES: COLLAPSED FUNCTION __aNlrem. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:07D6 ; [00000022 BYTES: COLLAPSED FUNCTION unknown_libname_1. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:07F8 ; [000000C4 BYTES: COLLAPSED FUNCTION __cinit. PRESS CTRL-NUMPAD+ TO EXPAND] 🔴 this used to come right after `start`
seg000:08BC ; [00000017 BYTES: COLLAPSED FUNCTION _exit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:08D3 ; [00000045 BYTES: COLLAPSED FUNCTION __exit. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0918 ; [0000002D BYTES: COLLAPSED FUNCTION __ctermsub. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0945 ; [0000000F BYTES: COLLAPSED FUNCTION sub_10945. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0954 ; [00000013 BYTES: COLLAPSED FUNCTION sub_10954. PRESS CTRL-NUMPAD+ TO EXPAND]
seg000:0967                 align 2
seg000:0968 ; [00000020 BYTES: COLLAPSED FUNCTION __FF_MSGBANNER. PRESS CTRL-NUMPAD+ TO EXPAND]

I did some experiments, and the order in which _fopen-_fread-_fwrite-… are placed in the executable doesn’t appear to be influenced by the order of calling or declaration in the C code. However, what I think happens here is that the order in which the linker pulls in individual object files from libc can be influenced by “some” factors, and this is what happened with /NOE. What this option does exactly is not documented at all in the compiler or linker documentation, nor is the associated L2044 linker error. It took some web searching to uncover this ancient bit of wisdom hidden in an old KB article:

The /NOEXTDICTIONARY switch tells the linker NOT to take advantage of additional information recorded in Extended Dictionary in the library file. This additional information describes which module in the library calls any other module from the same library, saving linker number of passes through the library file to pick up all required modules.
If you have a call in your code to the library function FOO and FOO calls another function BAR from the same library, then at processing time of FOO, the linker will pull out BAR. This process occurs because the extended dictionary has a link between FOO and BAR.
Linking without /NOE causes the following error if you want to pull FOO in from the library but you want to provide its own BAR:
L2044 BAR : symbol multiply defined, use /NOE
This error resulted from the linker pulling FOO and BAR from the same library, then later it sees BAR coming from user .OBJ file.
Using /NOE in this case prevents the linker from pulling out BAR from the library, so your BAR routine is used instead.
If you have genuine symbol redefinition, then when linking with /NOE you will see the following error:
L2025 BAR : symbol defined more than once

So what I think happened here is that before I added /NOE, things like __cinit and _exit were pulled in early as dependencies of start. Once I added it, the early processing did not happen, which is why they were pulled in later – presumably after the entire hello.obj object file was processed and symbols were still found missing.

It’s kind of weird that it takes an undocumented switch to make the linker operate in a way that’s considered standard nowadays, but I guess back then it was not common to use libraries other than the ones shipping with the compiler (the docs don’t even mention how to create libraries, it’s handled by a separate tool (LIB.EXE) and covered in the “CodeView and Utilities” document) and linking took a long time, so it made sense to try and optimize it. In any case, I got the idea to add an explicit call to exit() in the source code as a way to force the compiler to pull it earlier. Surprisingly, that was actually the golden ticket which put all the functions in the exact same order as they have in the game executable. Victory!

// hello.c
void itoa() {}
void _setargv() {}
void _setenvp() {}
void strcat() {}
void strcpy() {}
void memcpy() {}

void exit();
void getch();
void fclose();
void fopen();
void fread();
void fwrite();
void lseek();
void strcmp();
void getche();
void movedata();
void inp();
void putch();
void abs();
void srand();
void rand();

void _aNldiv();
void _aNlmul();
void _aNlrem();
void _aNNaldiv();

/* initialized data */
const char foobar[] = "hello_c_dat";
int barfoo = 0xabcd;
const char foobaz[] = "something_else";
/* uninitialized (BSS) data */
int x;
int y;

int main() {
    rand();
    fclose();
    fopen();
    fread();
    fwrite();
    lseek();
    strcmp();
    getche();
    getch();
    movedata();
    inp();
    putch();
    abs();
    srand();

    _aNldiv();
    _aNlmul();
    _aNlrem();
    _aNNaldiv();

    exit();

    x = 1;
    y = 2;
    return 0;
}
ninja@dell:eaglestrike$ make hello
cl /Gs /c /Foe:\hello.obj hello.c
link /M /I /NOD /NOE hello.obj,d:\hello.exe,,slibce.lib;
ls -l build-f15-se2/hello.exe
-rw-r--r-- 1 ninja ninja 6855 Dec 30  2023 build-f15-se2/hello.exe

The executable is a little short of 7kB in size now. That’s the overhead of libc on the game, about 15%. But now let’s look at the data segment.

Within the initialized data, not much has changed. Starting from the top we have:

  1. Some null bytes from the startup code
  2. The Microsoft libc watermark followed by 0x11, 0x0
  3. My data
  4. The remaining (non-startup) libc initialized data terminating with 3x0xff. Not surprisingly, there’s more of it than last time.

The problem lies in the layout of the BSS (uninitialized) section:

dseg:0346                 db 0FFh
dseg:0347                 db 0FFh
dseg:0348                 db 0FFh 🟢 libc initialized data ends here, BSS begins
dseg:0349                 db 407h dup(   ?)       ; 😭😭😭
dseg:0750 word_11CD0      dw ?                    ; x
dseg:0752 word_11CD2      dw ?                    ; y
dseg:0754                 db 20Ch dup(   ?)
dseg:0754 dseg            ends

Compared to last time (where there were 11 bytes of BSS with my data placed last), now the BSS consists of two featureless blocks with the sentinel data laying square in the middle, at a seemingly random offset.

I was really hoping for something simple like the 11 bytes of startup code again, followed by my data, with the libc data coming last, in a size that I could match to a block of ?s at the game’s BSS’ end. No such luck.

This seems like little return for a lot of work, but I’ve got some ideas to follow up with:

  • do more investigation into the libc code, surely it must reference the BSS data, it’s weird that IDA doesn’t have any references to it, maybe it just needs some manual tagging somewhere
  • try removing all the functions and readding them one by one, try to match BSS regions with libc modules
  • keep reversing the game, marking up all uninitialized data used by the game means what remains comes from libc - the hard way

This will have to wait for the next time next time though, since this post is already too long.