Patching Airline Tycoon Deluxe

A few weeks ago I bought four copies of the 2003 game Airline Tycoon Deluxe with the intent of playing some multiplayer games with family members. Steam didn’t list it as a multiplayer game but, from memory, I was pretty sure that it supported playing over a local network. Unfortunately, when I tried this it worked okay with two players but with three or four players the game froze on the dreaded end-of-day screen:

Other players have reported the same issue here, here and here. As it it turns out, the reason Steam doesn’t list the game as multiplayer is because the multiplayer mode does not work.

In this blog post we come up with a patch for version 1.03 of the game’s executable, At.exe, to fix this particular problem. There is an excellent open source debugger, x86dbg, we use for making sense of the x86 assembly code and making changes. It also has the ability to save and apply patchfiles that you can use to patch your At.exe if you want to.

For this task I enlisted the help of my enthusiastic 13 year old son.

The bug

Surprisingly, there is some source code available for a version of the game too. This makes for a very useful reference to make sense of the x86 assembly code.After some digging in the source code a particular bit of code in Sim.cpp caught our interest. The bug happens just before the game hits midnight, so this if statement seemed very relevant:

if (Sim.Time >= 24 * 60000)
{
  Sim.NewDay();
  Airport.NewDay();
}

To try and get some insight as to this code’s involvement in the bug we need to find the associated addresses in the executable. A really helpful function in x86dbg is that you can look for instances of specific integer constants. By looking for 15F900 (which is in hexadecimal) we found this if statement in the executable at address 004CAC8E:

As it turns out, the if statement condition is triggered at the time of the bug. You can confirm this by placing a breakpoint at 004CAC90 and watching it get hit. However, if you place a breakpoint at 004CAC9A it never gets triggered. That’s it then! When we hit midnight, Sim.NewDay() never returns!

The best part about this discovery is that we can now bisect the code. Also, by following the call addresses we have a way of mapping assembly code into c++ code. Through many hours days of work we found that Sim.NewDay() calls SIM::NewDay() which calls calls PLAYER::NewDay() four times which then in turn calls a function called NetGenericSync a few times with different values for a parameter called SyncId. It is in NetGenericSync where the game gets ‘stuck’ in an infinite loop that it is unable to escape.

Let’s have a closer look at NetGenericSync . This is what the c++ source code of this function looks like:

void NetGenericSync (long SyncId)
{
    if (!Sim.bNetwork) return;
    if (Sim.localPlayer<0 || Sim.localPlayer>3) return;

    Sim.SendSimpleMessage(
    	ATNET_GENERICSYNC,
    	NULL,
    	Sim.localPlayer,
    	SyncId);  

    GenericSyncIds[Sim.localPlayer]=SyncId;

    while (1)
    {
       long c;
       for (c=0; c<4; c++)
          if (Sim.Players.Players[c].Owner!=1 
          		&& GenericSyncIds[c]!=SyncId 
          		&& !Sim.Players.Players[c].IsOut)
             break;

       if (c==4) return;

       PumpNetwork();
    }
}

Here, PumpNetwork is a function that processes incoming messages. When the application receives a message from another player it sets GenericSyncIds[c] to the SyncId in the received message, where c is the player’s index.

By doing extensive logging we found that the following is happening (simplified):

  1. Player 1 sends a message to all players (with SyncId=A).
  2. Player 2 sends a message to all players (with SyncId=A).
  3. Player 1 sees player 2 is sync’ed (on SyncId=A) but player 3 is not.
  4. Player 2 sees player 1 is sync’ed (on SyncId=A) but player 3 is not.
  5. Player 3 sends a message to all players (with SyncId=A).
  6. Player 2 sees player 1 and 3 are sync’ed on SyncId=A exits the function and moves on to SyncId=B.
  7. Player 2 sends a message to all players (with SyncId=B).
  8. Player 1 sees player 3 is sync’ed (on SyncId=A) but player 2 is not because it already moved on to SyncId=B.

Basically, before player 1 could see player 3 had sync’ed on SyncId=A, player 2 had already moved on to SyncId=B, making it impossible for any player to move forward from that point onward.

For your perusal, the .exe’s assembly code for NetGenericSync looks like this:

The two big jumps at the start are for the two if statements. Then, the outer yellow arrow is the return jump for the while loop. The for loop’s return jump is the inner yellow arrow. The break statement is the little jump at 0046D618. The comparison, c==4, can be found at 0046D71C and the call to NetworkPump at 0046D724. Hopefully that gives you enough of a reference to work out what this assembly code does.

Fixing the logic

To fix the logic we have to stop checking the latest, most up-to-date SyncId for each player and instead keep track of which players have sync’ed to our SyncId at some point (even if they have moved on to a new SyncId since).

Fortunately, the way that NetworkPump() is implemented means we do see every single change in inGenericSyncIds provided we check each player following a return from NetworkPump. This means we should we should check every player’s sync condition following every return from NetworkPump and remember when a player meets the sync condition indefinitely. We then break out the while loop once all players have met this condition at some point.

In short, we need to change our logic to something like this:

void NetGenericSync (long SyncId)
{
    if (!Sim.bNetwork) return;
    if (Sim.localPlayer<0 || Sim.localPlayer>3) return;

    Sim.SendSimpleMessage(
    	ATNET_GENERICSYNC,
    	NULL,
    	Sim.localPlayer,
    	SyncId);

    GenericSyncIds[Sim.localPlayer]=SyncId;

    bool sync = 0;

    while (1)
    {
       long c;
       for (c=0; c<4; c++)
          if (Sim.Players.Players[c].Owner==1
          		|| GenericSyncIds[c]==SyncId
          		|| Sim.Players.Players[c].IsOut)
             sync |= 0x01 << (c*8);

       if (sync==0x01010101) return;

       PumpNetwork();
    }
}

We introduced a 32-bit sync variable which is initially 0. Whenever player c meets the sync condition we remember this by setting the associated byte of sync to 0x01. We check whether all players have at some point matched the sync condition by checking sync is equal to 0x01010101.

Fixing the exe

Making the logical change above by changing the .exe file is… difficult. You can’t just add a line of code to an executable, as it would change all the addresses in the code that follow, which messes jumps and function calls. You also can’t just add a local variable or call another function without being very careful and changing a bunch of stack-handling code. The sensible option is to jump to some unused bit of .text and insert additional instructions there.

And this is exactly what we did. Our changes follows the C++ changes above closely, except it has a bunch of jumps and instead of using a local variable we use one of the last addresses in the .exe’s data section, 0x006D0FF0, to store the sync state.

The patchfile looks like this (you can download it here):

>at.exe
0006D69A:68->E9
0006D69B:01->4B
0006D69C:17->08
0006D69D:AA->22
0006D69E:AD->00
0006D6D9:42->45
0006D719:02->B1
0006D71A:EB->E9
0006D71B:AF->F4
0006D71C:83->07
0006D71D:7D->22
0006D71E:FC->00
0006D71F:04->E9
0006D720:75->DA
0006D721:02->07
0006D722:EB->22
0006D723:07->00
0028DEEA:00->68
0028DEEB:00->01
0028DEEC:00->17
0028DEED:00->AA
0028DEEE:00->AD
0028DEEF:00->C7
0028DEF0:00->05
0028DEF1:00->F0
0028DEF2:00->0F
0028DEF3:00->6D
0028DEF9:00->E9
0028DEFA:00->A1
0028DEFB:00->F7
0028DEFC:00->DD
0028DEFD:00->FF
0028DEFE:00->81
0028DEFF:00->3D
0028DF00:00->F0
0028DF01:00->0F
0028DF02:00->6D
0028DF04:00->01
0028DF05:00->01
0028DF06:00->01
0028DF07:00->01
0028DF08:00->0F
0028DF09:00->84
0028DF0A:00->1D
0028DF0B:00->F8
0028DF0C:00->DD
0028DF0D:00->FF
0028DF0E:00->E9
0028DF0F:00->11
0028DF10:00->F8
0028DF11:00->DD
0028DF12:00->FF
0028DF13:00->8B
0028DF14:00->45
0028DF15:00->FC
0028DF16:00->C6
0028DF17:00->80
0028DF18:00->F0
0028DF19:00->0F
0028DF1A:00->6D
0028DF1C:00->01
0028DF1D:00->E9
0028DF1E:00->A9
0028DF1F:00->F7
0028DF20:00->DD
0028DF21:00->FF

I won’t go into details as to how this implements the functional changes.

To share our fix with the world we are making the patch file available. With this patch file you can create a patched executable as follows:

  • Locate the Airline Tycoon Deluxe executable At.exe.1
  • Make a backup of your At.exe file so that you can revert to the original file if need be.
  • Download and install x86dbg.
  • Open your At.exe file in x86dbg.
  • In x86dbg, go to File > Patch File.
    • Select Import to import our patch file.
    • Select Patch File to write out a patched executable.
  • Run the patched executable.

Note that all players will need to have the patched executable for the fix to work.

Conclusion

In our experiments the above patch fixes the multiplayer bug. We suspect there may be a few more unresolved issues in the game that may arise in multiplayer mode, though. Either way, let us know how you get on!

  1. The patchfile has only been tested for the At.exe for version 1.03 of the game (MD5 8a2b226a2453bdf46dd0709007b2a2f9). If you have a different executable this patch will likely not work.