(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.)

I am happy to say the reconstruction for EGAME.EXE is progressing smoothly.(…) –me, more than a year ago

Famous last words, right? After typing that, I had made some more progress on the single biggest routine of the egame executable, which is still called otherKeyDispatch() (for lack of a better name, because it seems to switch on a keycode value), and then all work on the project effectively ceased. I worked on completing the routine seldomly, and just couldn’t get through it. It was not about some new difficulty, I had just become completely burned out by the reconstruction process with only about 30 lines of C to go, and unable to continue.

It didn’t help that I also made a slight detour while (not) working on the reconstruction. I had mused about applying a “neural network” to the project as far back as 2022, but back then it seemed like something out of science fiction. However we’re now living through an LLM revolution (leaving the behaviour of financial markets and social and environmental costs out of it for now), and having had a taste myself at work, I decided it would be worth trying to delegate some of the most boring and repetetive work. Having incomplete or totally hallucinated answers would be fine because I have my tooling and could verify the results easily enough. Really any amount of assistance would have been invaluable to me. And the task really is perfect for applying an LLM - it’s completely mechanistic, non-creative, based on correlative knowledge that’s implicit from the relationship between the binaries, the old DOS C compiler, and the reconstructed source code, and never explicitly stated.

But I wasn’t happy with just using Copilot from VS Code. This project was always a little about keeping my interest in technology fresh, so I decided to learn a little bit more and create my own, self-hosted setup for this purpose, using one of the free open weights models. I had recently bought a decent GPU so I used Ollama to run the models, and the Continue extension to bridge the model to VS Code. It’s a long story that I want to address separately at some point, but the bottom line was that I did not obtain very useful results this way. The biggest models I could realistically run on my hardware fell a little bit short, and I concluded that my setup was lacking enough high quality context for the model to operate off of. So I spent additional time developing a custom RAG solution to create and employ a database of assembly-to-C source snippet database that the model could use as a reference without having it ingest my entire codebase (which wouldn’t fit anyway).

All of this took a lot of time without having anything to show for my efforts, and depression and guilt slowly creeping in. This is when I got an intriguing message from a potential collaborator.

Christmas comes early

I was being graced by the presence of an experienced reverse engineer and veteran of mutiple successful decompilation projects, who quickly started producing an almost overwheling stream of commits and PRs into my repository. Using LLMs, he astonishingly was able to basically start and finish reconstructing the last executable, end.exe within a couple days, with me just having to fix a couple simple problems that Sillicon Steve was unsuccessful at resolving. I quickly decided to abandon my old workflow which was dependent on IDA, because synchronizing access to binary .idb databases in git was not going to be feasible, and would only hamper progress on the reconstruction, which was then happening at lightning speed. We now have:

  • LLM-generated routine/variable names in start.exe which contained mostly stub names despite being fully reconstructed by me. It’s fine if not all of these are spot on, names are easier to deal with than random numbers and can be changed later as more information is added.
  • a fully reconstructed, debugged and working end.exe, also with some autogenerated symbol names.
  • a somewhat functional but still unstable main game executable (egame.exe), with most (90%+) C code reconstructed, and the rest in progress.

So, we seem to have had a minor resurrection miracle in the project, and I wish to extend my sincerest thanks to @AJenbo for showing up and saving the day when he did.

It’s worth to mention here that all of this would not have been possible without the work @AJenbo did on Ghidra to make it better support 16bit code. The Ghidra decompiler is pretty great, and I’ve used it before, but it was a grind because it does not really support segmented addressing and some quirks of the 16bit architecture. With that support added in, the output of Ghidra is used as a starting point for LLM agents which iterate over the reconstruction, invoking mzretools to check their work. But those sweet 16bit changes are not likely to be accepted into upstream Ghidra, so as far as I understand, @AJenbo’s repository remains the single location where this tooling can be obtained.

So, for now at least, the project has had new life pumped into it and is back on track, thanks to collaborators (there are multiple now), whom I wish to personally thank for their effort and dedication.

Back into it now

This blog was always about documenting some of the more esoteric quirks of the MS C compiler, and I have a fresh supply of those from the latest trove of going through the reconstruction that I wish to present. But I must say that working on code that has been pre-chewed by Ghidra and LLMs is much more pleasant than having to write everything out manually. I can focus on the meaningful differences where the machine was unable to make progress, and where out-of-the-box thinking might still be useful.

Some registers are more unsigned than others

We’re looking at this C source line:

gfx_copyRect(2, b - 3, c - 3, byte_3C5A0, b - 3, c - 3, 6, 6);

Unfortunately this does not match when comparing, the original loads the value of c and b into si to perform the subtraction, while our reconstruction uses ax instead:

0000:0b54/000b54: mov si, [bp-0x06]                != 0000:0715/000715: mov ax, [bp-0x06] ; 🤨
ERROR: Instruction mismatch in routine updateFrame at 0000:0b54/000b54: mov si, [bp-0x06] != 0000:0715/000715: mov ax, [bp-0x06]
--- Context information for up to 20 additional instructions of routine updateFrame after mismatch location:
0000:0b57/000b57: sub si, 0x3                      != 0000:0718/000718: sub ax, 0x3 
0000:0b5a/000b5a: mov di, [bp-0x08]                != 0000:071b/00071b: mov si, ax
0000:0b5d/000b5d: sub di, 0x3                      != 0000:071d/00071d: mov ax, [bp-0x08]
0000:0b60/000b60: mov ax, 0x6                      != 0000:0720/000720: sub ax, 0x3
[...]

I couldn’t tell you why, but the signedness of the argument in the function declaration makes the difference:

// wrong
int FAR CDECL gfx_copyRect(int srcPage, int srcX, int srcY, int dstPage, int dstX, int dstY, int width, int height);
// right
int FAR CDECL gfx_copyRect(int srcPage, uint16 srcX, uint16 srcY, int dstPage, uint16 dstX, uint16 dstY, int width, int height);

I don’t know why, but my intuition with seeing nonsense like this is usually to try fiddling with the signedness. Why is si better for unsigned? No idea, but that’s what MS C does.

This pointer is HUGE

Ghidra sure made a mess of this one and the LLM couldn’t figure it out:

if (*(int far *)((char far *)commData - 4) != (int)0xca01 ||
    *(int far *)((char far *)commData - 2) != 0x3b9a) {

This is a condition which checks whether the MCB preceeding the COMM structure (which is used to communicate data between different parts of the game) controls a magic checksum. If not, it quits the simulation immediately. I traced the register values while the equivalent assembly code is executing.

seg000:0CEA		    mov	    ax,	0FFFCh
seg000:0CED		    cwd                               ; dx:ax = ffff fffc
seg000:0CEE		    add	    ax,	word ptr commData     ; [commData] = 0
seg000:0CF2		    adc	    dx,	0                     ; no change
seg000:0CF5		    mov	    cx,	0Ch
seg000:0CF8		    shl	    dx,	cl                    ; dx = f000
seg000:0CFA		    add	    dx,	word ptr commData+2   ; [commData+2] = 1554, dx = 554
seg000:0CFE		    mov	    es,	dx
seg000:0D00		    mov	    bx,	ax
seg000:0D02		    cmp	    word ptr es:[bx], 0CA01h  ; es:bx = 554:fffc
seg000:0D07		    jnz	    short loc_10D11
seg000:0D09		    cmp	    word ptr es:[bx+2],	3B9Ah ; magic checksum: 0x3b9aca01
seg000:0D0F		    jz	    short loc_10D20

It uses the immediate value of 0xfffc which is equivalent to -4 to step backwards from the far address of the allocated structure (1554:0 in this case) to land at the expected location of the checksum, then dereferences the obtained pointer (554:fffc) for the check. Unfortunately, the code generated by the compiler from the Ghidra decompilation doesn’t match:

0000:08ab/0008ab: mov ax, [0xa104] ; commData
0000:08ae/0008ae: mov dx, [0xa106]
0000:08b2/0008b2: sub ax, 0x4
0000:08b5/0008b5: sbb dx, 0x0
0000:08b8/0008b8: mov es, dx
0000:08ba/0008ba: mov bx, ax
0000:08bc/0008bc: cmp word es:[bx], 0x3b9a
0000:08c1/0008c1: jnz 0x8cb (0xa down)
0000:08c3/0008c3: cmp word es:[bx+0x02], 0xca01
0000:08c9/0008c9: jz 0x8da (0x11 down)

This had me scratching my head for a while before realizing that far pointers perform arithmetic on the offset part only, and this add/adc/shl sequence seems to be performing addition with carry on a full 32bit far pointer value, which is exactly what “huge” pointers are for under DOS. The other part of the insight is that the two cmp instructions seem to operate on the same logical value, so it’s not likely to be two separate comparisons in the actual C code. Sure enough, it was just a matter of casting the pointer to the structure into a huge pointer to a single int32, going back one (4 bytes) and dereferencing:

if (*((int32 huge *)commData - 1) != 0x3b9aca01) {

Interestingly, now it becomes evident that the magic value of 0x3b9aca01 is actually one billion and one. I was curious about that, but it didn’t make sense why this particular value was picked when looking at it in halves, or as ASCII codes.

Compiler entropy

Until @AJenbo pointed this one out to me, I must admit I was a little bit naive, assuming that MS C was so simple and well behaved that it exhibited no non-deterministic behaviour and that I had Special Powers in predicting (or post-rationalizing) what it would do exactly in a given situation. Oh you sweet summer child.

It begins simple enough with reconstructing a short C routine:

void sub_160D3(int *arg_0) {
    while (*arg_0 != -1) {
        gfx_jump_21(((uint8 *)word_3419C)[*arg_0++]);
        sub_2171A();
        arg_0 += 2;
        while (*arg_0 != -1) {
            var_351 = arg_0[-2];
            var_353 = arg_0[-1];
            var_352 = *arg_0++;
            var_354 = *arg_0++;
            sub_2189C();
        }
        sub_21704();
        arg_0++;
    }
}

Rebuilding the reconstruction and running it through verification fails, but oddily enough not in the routine which was just reconstructed, but a different one:

WARNING: Unable to determine location of routine sub_11841 in target executable. Last resort pattern searching found likely location 0000:7f95/007f95, but it may be completely wrong so false negative or positive is possible!
--- Now @0000:1841/001841, routine 0000:1841-0000:18d4[000094]: sub_11841 [near] [complete], block 001841-0018d4[000094], target @0000:7f95/007f95
0000:1841/001841: push bp                          == 0000:7f95/007f95: push bp
0000:1842/001842: mov bp, sp                       == 0000:7f96/007f96: mov bp, sp
0000:1844/001844: sub sp, 0x4                      =~ 0000:7f98/007f98: sub sp, 0xc
0000:1847/001847: push di                          == 0000:7f9b/007f9b: push di
0000:1848/001848: push si                          == 0000:7f9c/007f9c: push si
0000:1849/001849: cmp word [0xe46], 0xff           ~~ 0000:7f9d/007f9d: cmp word [0xe8a], 0x0 ; var_116 / ?
0000:184e/00184e: jz 0x18cf (0x81 down)            ~= 0000:7fa2/007fa2: jz 0x7fad (0xb down)
0000:1850/001850: mov word [bp-0x02], 0x0          != 0000:7fa4/007fa4: mov ax, [0xe76]

The warning was actually not originally there, which made this significantly more mysterious. In general, mzdiff adds code locations to its scan queue based on calls and jumps it sees while comparing opcodes. In this case, it never saw a call to sub_11841, but it was present in the map, so its address was added to the scan queue, but without a corresponding target location in the reconstructed executable. Then, just as the warning says, it attempted a last resort match by scanning for the exact opcode bytes from the target, increasing the number of the bytes until all matches except one was found. In this case, this process yielded the location 0x7f95, but this is a red herring. I’m not exactly sure what routine this is, because it came from assembly and is not public, so I can’t see it in the linker map, but it doesn’t really matter. What I can see is the real sub_11841 though:

ninja@RYZEN:f15se2-re$ grep sub_11841 build/egame.map
 0000:327A       _sub_11841

Let’s manually set the comparison offsets with mzdiff to check this routine:

ninja@RYZEN:f15se2-re$ mzdiff --verbose --loose bin/egame.exe:0x1841 build/egame.exe:0x327a
Comparing code between reference (entrypoint 0000:1841/001841) and target (entrypoint 0000:327a/00327a) executables
New comparison location 0000:1841/001841, queue size = 0
--- Comparing reference @ 0000:1841/001841 to target @0000:327a/00327a
WARNING: Unable to find target entrypoint for routine unknown
0000:1841/001841: push bp                          == 0000:327a/00327a: push bp
0000:1842/001842: mov bp, sp                       == 0000:327b/00327b: mov bp, sp
0000:1844/001844: sub sp, 0x4                      == 0000:327d/00327d: sub sp, 0x4
0000:1847/001847: push di                          == 0000:3280/003280: push di
0000:1848/001848: push si                          == 0000:3281/003281: push si
0000:1849/001849: cmp word [0xe46], 0xff           ~= 0000:3282/003282: cmp word [0xe88], 0xff
0000:184e/00184e: jz 0x18cf (0x81 down)            != 0000:3287/003287: jnz 0x328c (0x5 down) ; BOOM!
ERROR: Instruction mismatch in routine unknown at 0000:184e/00184e: jz 0x18cf != 0000:3287/003287: jnz 0x328c
--- Context information for up to 10 additional instructions of routine unknown after mismatch location:
0000:1850/001850: mov word [bp-0x02], 0x0          != 0000:3289/003289: jmp 0x3314 (0x8b down)
0000:1855/001855: jmp short 0x185a (0x5 down)      != 0000:328c/00328c: mov word [bp-0x02], 0x0
0000:1857/001857: inc [bp-0x02]                    != 0000:3291/003291: jmp short 0x3296 (0x5 down)
0000:185a/00185a: cmp word [bp-0x02], 0x8          != 0000:3293/003293: inc [bp-0x02]
0000:185e/00185e: jge 0x187f (0x21 down)           != 0000:3296/003296: cmp word [bp-0x02], 0x8
0000:1860/001860: mov si, [bp-0x02]                != 0000:329a/00329a: jge 0x32bb (0x21 down)
0000:1863/001863: mov cl, 0x3                      != 0000:329c/00329c: mov si, [bp-0x02]
0000:1865/001865: shl si, cl                       != 0000:329f/00329f: mov cl, 0x3
0000:1867/001867: add word [si+0x0b56], 0xa        != 0000:32a1/0032a1: shl si, cl
0000:186c/00186c: mov ax, [si+0x0b56]              != 0000:32a3/0032a3: add word [si+0x0b98], 0xa

The early difference made the opcode scanning last resort heuristic reject this as a viable comparison location for sub_11841. The original jz +0x81 turned into jnz +5; jmp +0x8b, because the destination for the jump went out of bounds for the relative 8bit jump of the jz instruction. Let’s try following this thread by again setting the comparison offsets and nudge mzdiff past this difference, which is just an artifact of a later discrepancy. This actually lets me show off the latest addition to mzdiff which is the display of symbol names from the maps of the compared executables in comments. This is useful since after ditching the manual, IDA-based reconstruction process we don’t have comments in the C code denoting the assembly offsets that I was relying on until now to find my way around the assembly - now the surest way is to look at the symbol names and try to correlate the problematic section to C code that way.

ninja@RYZEN:f15se2-re$ mzdiff --verbose --loose bin/egame.exe:0x1850 build/egame.exe:0x328c --map map/egame.map --tmap build/egame.map:link
Loading target map from build/egame.map, tag: link
Comparing code between reference (entrypoint 0000:1850/001850) and target (entrypoint 0000:328c/00328c) executables
New comparison location 0000:1850/001850, queue size = 0
--- Now @0000:1850/001850, routine 0000:1841-0000:18d4[000094]: sub_11841 [near] [complete], block 001841-0018d4[000094], target @0000:328c/00328c
0000:1850/001850: mov word [bp-0x02], 0x0          == 0000:328c/00328c: mov word [bp-0x02], 0x0
0000:1855/001855: jmp short 0x185a (0x5 down)      == 0000:3291/003291: jmp short 0x3296 (0x5 down)
0000:1857/001857: inc [bp-0x02]                    == 0000:3293/003293: inc [bp-0x02]
0000:185a/00185a: cmp word [bp-0x02], 0x8          == 0000:3296/003296: cmp word [bp-0x02], 0x8
0000:185e/00185e: jge 0x187f (0x21 down)           == 0000:329a/00329a: jge 0x32bb (0x21 down)
0000:1860/001860: mov si, [bp-0x02]                == 0000:329c/00329c: mov si, [bp-0x02]
0000:1863/001863: mov cl, 0x3                      == 0000:329f/00329f: mov cl, 0x3
0000:1865/001865: shl si, cl                       == 0000:32a1/0032a1: shl si, cl
0000:1867/001867: add word [si+0x0b56], 0xa        ~= 0000:32a3/0032a3: add word [si+0x0b98], 0xa ; var_90 / ?
0000:186c/00186c: mov ax, [si+0x0b56]              =~ 0000:32a8/0032a8: mov ax, [si+0x0b98] ; var_90 / ?
0000:1870/001870: mov cl, 0x9                      == 0000:32ac/0032ac: mov cl, 0x9
0000:1872/001872: sar ax, cl                       == 0000:32ae/0032ae: sar ax, cl
0000:1874/001874: add [si+0x0b54], ax              ~= 0000:32b0/0032b0: add [si+0x0b96], ax ; var_89 / ?
0000:1878/001878: add byte [si+0x0b59], 0x6        ~= 0000:32b4/0032b4: add byte [si+0x0b9b], 0x6 ; var_92 / ?
0000:187d/00187d: jmp short 0x1857 (0x26 up)       == 0000:32b9/0032b9: jmp short 0x3293 (0x26 up)
0000:187f/00187f: test byte [0xe38], 0xf           ~= 0000:32bb/0032bb: test byte [0xe7a], 0xf ; var_109 / word_336E8
0000:1884/001884: jnz 0x18cf (0x4b down)           ~= 0000:32c0/0032c0: jnz 0x3314 (0x54 down)
0000:1886/001886: mov ax, [0xe38]                  =~ 0000:32c2/0032c2: mov ax, [0xe7a] ; var_109 / word_336E8
0000:1889/001889: mov cl, 0x4                      == 0000:32c5/0032c5: mov cl, 0x4
0000:188b/00188b: sar ax, cl                       == 0000:32c7/0032c7: sar ax, cl
0000:188d/00188d: and ax, 0x7                      == 0000:32c9/0032c9: and ax, 0x7
0000:1890/001890: mov [bp-0x04], ax                == 0000:32cc/0032cc: mov [bp-0x04], ax
0000:1893/001893: mov si, ax                       == 0000:32cf/0032cf: mov si, ax
0000:1895/001895: mov cl, 0x3                      == 0000:32d1/0032d1: mov cl, 0x3
0000:1897/001897: shl si, cl                       == 0000:32d3/0032d3: shl si, cl
0000:1899/001899: mov di, [0xe46]                  =~ 0000:32d5/0032d5: mov di, [0xe88] ; var_116 / word_336F6
0000:189d/00189d: mov cl, 0x4                      == 0000:32d9/0032d9: mov cl, 0x4
0000:189f/00189f: shl di, cl                       == 0000:32db/0032db: shl di, cl
0000:18a1/0018a1: mov ax, [di-0x7e52]              =~ 0000:32dd/0032dd: mov ax, [di-0x7b26] ; var_761 / stru_3AA5E
0000:18a5/0018a5: mov [si+0x0b52], ax              ~= 0000:32e1/0032e1: mov [si+0x0b94], ax ; var_88 / stru_33402
0000:18a9/0018a9: mov ax, [di-0x7e50]              =~ 0000:32e5/0032e5: mov ax, [di-0x7b24] ; var_762 / ?
0000:18ad/0018ad: mov [si+0x0b54], ax              ~= 0000:32e9/0032e9: mov [si+0x0b96], ax ; var_89 / ?
0000:18b1/0018b1: mov word [si+0x0b56], 0x80       ~= 0000:32ed/0032ed: mov word [si+0x0b98], 0x80 ; var_90 / ?
0000:18b7/0018b7: mov ax, 0x100                    == 0000:32f3/0032f3: mov ax, 0x100
0000:18ba/0018ba: push ax                          == 0000:32f6/0032f6: push ax
0000:18bb/0018bb: call 0xd200 (0xb945 down)        ~= 0000:32f7/0032f7: call 0x4000 (0xd09 down) ; randlmul / sub_1D200
0000:18be/0018be: add sp, 0x2                      == 0000:32fa/0032fa: add sp, 0x2
0000:18c1/0018c1: mov ch, al                       == 0000:32fd/0032fd: mov ch, al
0000:18c3/0018c3: sub cl, cl                       == 0000:32ff/0032ff: sub cl, cl
0000:18c5/0018c5: mov [si+0x0b58], cx              != 0000:3301/003301: mov bx, [bp-0x04] ; var_91 / ? ; 💥💥💥
ERROR: Instruction mismatch in routine sub_11841 at 0000:18c5/0018c5: mov [si+0x0b58], cx != 0000:3301/003301: mov bx, [bp-0x04]
--- Context information for up to 10 additional instructions of routine sub_11841 after mismatch location:
0000:18c9/0018c9: mov ax, [bp-0x04]                != 0000:3304/003304: mov ax, cx
0000:18cc/0018cc: mov [0xb92], ax                  != 0000:3306/003306: mov cl, 0x3
0000:18cf/0018cf: pop si                           != 0000:3308/003308: shl bx, cl
0000:18d0/0018d0: pop di                           != 0000:330a/00330a: mov [bx+0x0b9a], ax
0000:18d1/0018d1: mov sp, bp                       != 0000:330e/00330e: mov ax, [bp-0x04]
0000:18d3/0018d3: pop bp                           != 0000:3311/003311: mov [0xbd4], ax
0000:18d4/0018d4: ret                              != 0000:3314/003314: pop si

It seems like for some reason the compiler decided to recalculate the location of an item in an array of structs, which it already had in si. This is a good time to actually look at the relevant C code:

int sub_11841() {
    int p;
    int a;

    if (word_336F6 != -1) {
        for (p = 0; p < 8; p++) {
            ((struct struc_9 *)stru_33402)[p].field_4 += 0x0a;
            ((struct struc_9 *)stru_33402)[p].field_2 += ((struct struc_9 *)stru_33402)[p].field_4 >> 9;
            *(((char *)&((struct struc_9 *)stru_33402)[p].field_6) + 1) += 6;
        }
        if (!((char)word_336E8 & 0x0f)) {
            a = (word_336E8 >> 4) & 7;
            ((struct struc_9 *)stru_33402)[a].field_0 = *(int16 *)((char *)stru_3AA5E + word_336F6 * 16);
            ((struct struc_9 *)stru_33402)[a].field_2 = *(int16 *)((char *)stru_3AA5E + word_336F6 * 16 + 2);
            ((struct struc_9 *)stru_33402)[a].field_4 = 0x80;
            ((struct struc_9 *)stru_33402)[a].field_6 = sub_1D200(0x100) << 8; // pointer to `stru_33402[a]` recalculated here for some reason
            word_33442 = a;
        }
    }
}

This is not pretty, because it came from decompilation and has not been refactored yet. But here’s the mind blowing stuff: this code matched before sub_160D3 was reconstructed above it. Then it stopped matching. Then, after sub_160D3 was moved to the end of the file, sub_11841 matched again.

In other words, the assumption that routines are processed by the compiler in isolation, and no routine can influence another, has been shattered. Clearly, there is some potential for state to propagate between routines (within the same C file) and influence the way they are put together by the compiler. Sorry, no matter how many times I say it, it still strikes me. When reconstructing code for MS C, your efforts may be derailed by invisible factors and no amount of beating your head against the wall will help.

Now, it’s not entirely all bad. There are some hints to what may be happening. @AJenbo had his LLM minions analyze this and the takeaway seems to be that it has to do with referencing far symbols, with 2+ references in a preceeding routine triggering this effect. It was described as a register spill by the LLM, which I don’t think is entirely accurate (a spill is when the compiler writes a register value to temporary storage on the stack when it runs out of registers), in fact it’s actually the reverse where a register value that could be reused, isn’t and the compiler feels obligated to flush the value and recalculate.

Also, adding routines which only reference near symbols appears to reset the state. I’m not sure how accurate this analysis really is, because it seems like the order the compiler sees globals in could also be a factor, but at least it seems fiddling with the order of routines (and/or globals?) can fix this. Additionally, I think the size of the C source file might be a factor. The happened in an almost ~2k LOC file, and I’m wondering whether the compiler isn’t running into memory pressure with big files and there’s no room for the symbol table anymore. Remember, this is 1989 and extended memory isn’t really popular in DOS, so the compiler needs to fit itself, the source and everything else in conventional memory (640k). Also suspicious is the fact that compiling the file yielded this warning:

egame1.c(1920) : warning C4073: scoping too deep, deepest scoping merged when debugging

The warning C4073 is documented as following in the compiler documentation:

Declarations appeared at a static nesting level greater than 13 . As a result, all declarations will seem to appear at the same level. (1)

It seems like the compiler seems to believe the code reached the thirteenth level of nesting at the last line of the file (1920). There’s definitely something weird happening here. As an experiment, I took half of the routines from egame1.c and moved them to a different, almost empty file, while keeping the “bad” order that triggered the issue. The warning disappeared and the code started matching again. So maybe it has to do with file size after all?

Another question is why I never saw this when reconstructing start.exe, and why we didn’t hit it with the end.exe reconstructed by @AJenbo. Both are smaller that egame.exe which might be a factor. Might also have been lucky. Besides, for start.exe I think I mostly kept the routines in the same order as they were present in the original executable, which might have mitigated the issue, or made it exhibit in the same locations where it did in the original. So that’s an another possibility for a mitigation.

Bottom line, when facing compiler entropy from MS C, try the following:

  1. Try to organize the routines in the same order as in the original file being reconstructed.
  2. Split large C files into smaller ones
  3. Move routines and/or global variables around, particularly paying attention to near vs far symbol usage. But this is brittle and can break again after adding more routines.

So I guess that’s it. As I’m writing this, some critical bugs have been fixed in egame.exe by @AJenbo and it looks pretty playable now. We still have a couple routines to complete, and a few bugs, but I’m confident these will be possible to overcome.