Theme Hospital, a 1997 strategy game, has data files with strings for an alluring debug menu. However, it’s missing from the menu bar in all released versions, even though it peeked out in some manuals:

In the following sections, we find a way to activate it, then we explore what remains of its features.

Symbol names and addresses are from DOS Demo Beta v2.0, unless stated otherwise.

Finding menu data

Let’s start with the Windows Beta 5 build. This time, dxwnd was able to handle the executable’s window setup, so debugging could be done with these steps:

  1. run in dxwnd
  2. attach x64dbg
  3. restart debuggee in x64dbg

What do we know about these menus? Well, the labels for the menu bar, so we can take a snippet (FILE OPTIONS ) from file lang-0.dat. Let’s attach the debugger and find this hex-encoded string in a 0x10000 sized memory range:

findallmem 00010000,46494c4520200020204f5054494f4e532020

We get a single match at address 01B4F332:

01B4F332  46 49 4C 45 20 20 00 20 20 4F 50 54 49 4F 4E 53  FILE  .  OPTIONS
01B4F342  20 20 00 20 20 44 49 53 50 4C 41 59 20 20 00 20    .  DISPLAY  .
01B4F352  20 43 48 41 52 54 53 20 20 00 20 20 44 45 42 55   CHARTS  .  DEBU
01B4F362  47 20 20 00 2E 00 20 20 4C 4F 41 44 20 20 00 20  G  ...  LOAD  .
01B4F372  20 53 41 56 45 20 20 00 20 20 52 45 53 54 41 52   SAVE  .  RESTAR
01B4F382  54 20 20 00 20 20 51 55 49 54 20 20 00 2E 00 20  T  .  QUIT  ...

Let’s put a hardware read breakpoint at the found address, and then mouse over the menu bar, in order to break in the menu handler routine:

0048C859 | cmp byte ptr ds:[esi+1],20    | esi+1:"FILE  ", 20:' '
0048C85D | je hospital.48C8A3            |
0048C85F | mov cl,byte ptr ds:[edi]      | edi:"FILE  "
0048C861 | cmp cl,9                      | 9:'\t'
0048C864 | je hospital.48C8A3            |
0048C866 | cmp cl,A                      | A:'\n'
0048C869 | je hospital.48C8A3            |
[...]
0048CF23 | mov ecx,dword ptr ss:[esp+18] | [esp+18]:"  FILE  "
0048CF27 | push eax                      |
0048CF28 | push ebx                      |
0048CF29 | push edx                      |
0048CF2A | push esi                      |
0048CF2B | push ecx                      | ecx:"  FILE  "
0048CF2C | call hospital.48CF90          |

By stepping around for a bit, and observing the register values, we see that this is iterating char by char over the menu label. This value is then used to update some menu data in memory, but that doesn’t seem very relevant for now, so let’s continue until ret and move up in the call stack, arriving at:

00427039 | call hospital.48C730             | ; previous routine
0042703E | add esp,C                        |
00427041 | cmp byte ptr ds:[507F2A],0       |
00427048 | je hospital.427068               |
[...]
00427068 | mov ax,word ptr ds:[esi+4C4E13]  |
0042706F | movzx cx,byte ptr ds:[4C4E0C]    |
00427077 | imul ax,cx                       |
0042707B | add bp,ax                        |
0042707E | inc bx                           | ; increment current menu idx
00427080 | xor ecx,ecx                      |
00427082 | movzx eax,bx                     |
00427085 | mov cl,byte ptr ds:[4C4E0D]      | ; load total number of menus
0042708B | cmp ecx,eax                      | ; are there menus left to parse?
0042708D | ja hospital.426FF4               |
[...]
00426FF4 | movzx edi,bx                     |
00426FF7 | lea eax,dword ptr ds:[edi+edi*4] |
00426FFA | mov word ptr ds:[507F20],0       |
00427003 | lea ecx,dword ptr ds:[edi+eax*8] |
00427006 | lea eax,dword ptr ds:[ecx+ecx*2] |
00427009 | sub eax,edi                      |
0042700B | lea esi,dword ptr ds:[eax+eax*4] |
0042700E | cmp byte ptr ds:[esi+4C4E10],0   | ; is menu enabled?
00427015 | je hospital.42707E               |
00427017 | xor eax,eax                      |
00427019 | mov ecx,dword ptr ds:[4DAF84]    | ; load address of base menu label (`NULL`)
0042701F | mov al,byte ptr ds:[esi+4C4E12]  | ; load current menu index
00427025 | mov edx,dword ptr ds:[ecx+eax*4] | ; load next label
00427028 | mov eax,dword ptr ds:[4E545A]    |
0042702D | push edx                         |
0042702E | mov ecx,dword ptr ds:[eax+54C]   |
00427034 | movzx edx,bp                     |
00427037 | push ecx                         |
00427038 | push edx                         | edx:"  OPTIONS  "
00427039 | call hospital.48C730             | ; add label and entries to menu bar

At this point, one of the registers has advanced to the next label (OPTIONS). If we continue to the other labels, we hit these blocks again, and we can observe something interesting: DEBUG also gets parsed! Ok, so there must be some check to decide if it gets shown. On top of that, it’s a check that must take the same conditional branch for all the other labels except this one. We can just test which comparisons from the above instructions satisfy these conditions. See address 0042700E?

To make it evident, compare esi+4C4E10 for the FILE menu (004C5072 = 1):

004C5068  00 00 00 00 00 00 00 00 00 00 01 04 01 08 00 0B  ................
004C5078  00 00 00 00 00 00 07 00 00 00 00 00 00 00 00 06  ................
004C5088  00 00 FF 01 00 00 00 00 00 00 08 00 00 00 00 00  ..ÿ.............
004C5098  00 00 00 07 00 00 FF 01 00 00 00 00 01 01 09 00  ......ÿ.........
004C50A8  00 00 00 00 00 00 00 00 00 00 FF 01 D0 9F 42 00  ..........ÿ.Ð.B.
004C50B8  01 01 0A 00 00 00 00 00 00 00 00 00 01 01 FF 00  ..............ÿ.

Against the DEBUG menu (004C59FA = 0):

004C59F8  00 00 00 1A 05 09 00 17 00 00 00 00 01 01 24 4B  ..............$K
004C5A08  E2 4D 00 00 00 00 00 00 00 00 FF 00 00 00 00 00  âM........ÿ.....
004C5A18  01 01 25 51 E2 4D 00 00 00 00 00 00 00 00 FF 00  ..%QâM........ÿ.
004C5A28  00 00 00 00 01 01 26 52 E2 4D 00 00 00 00 00 00  ......&RâM......
004C5A38  00 00 FF 00 00 00 00 00 01 01 27 49 E2 4D 00 00  ..ÿ.......'IâM..

If we override the value set in memory whenever we hit this comparison:

SetBPX 0042700E
SetBreakpointCondition 0042700E,0
SetBreakpointCommand 0042700E,"byte:[4C59FA]=01"
SetBreakpointCommandCondition 0042700E,1

We get an extra menu! 1

To avoid having to attach a debugger, we might as well patch the binary. Some of the values at these address are only set in runtime, so the file offset query has to take that into account. Since stored addresses can also change across builds, we can use the filler bytes as signature:

binwalk -R '\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x01' Hospital.exe

Here are the offsets for several builds, which can be identified by the build date and version strings in the corresponding binary:

Offset Release Build Date Version
0x149F56 DOS Demo Mar 18 1997 Beta v2.0
0xC2DFE Windows Demo Mar 10 1997 Beta v2.0
0x16C40C DOS Retail Mar 07 1997 Beta 4
0xC295C Windows Retail Mar 07 1997 Beta 4
0x16F36E DOS Retail May 13 1997 Beta 5
0xC43FA Windows Retail May 13 1997 Beta 5

Tracing menu handlers

Like the other menus, some of the entries are toggles (when set, a check mark is shown). It wasn’t clear what these entries were doing, so the next step was to check for memory changes, so that we could lookup cross-references (xrefs) for those addresses in the dissassembly.

If you go back to the memory snippet above, you can see some addresses encoded in little-endian (e.g. 4DE251, 4DE252…). Whenever a toggle is activated, the corresponding address is set to 1. A bit further, one of them sticks out by appearing in a different offset (relative to that ff delimiter), besides having a comparatively smaller value (41E170):

000c44c0: 0000 0000 0000 ff00 0000 0000 0101 2e4a  ...............J
000c44d0: e24d 0000 0000 0000 0000 ff00 0000 0000  .M..............
000c44e0: 0101 2f00 0000 0000 0000 0000 0000 ff00  ../.............
000c44f0: 70e1 4100 0101 3050 e24d 0000 0000 0000  p.A...0P.M......

At this point, we can switch to Ghidra, convert addresses with:

target address - .data byte source + .data start address =
target address - 0xa9a00 + 0x4ab000

Then label these converted addresses, and check for references:

004de23d       ??         ??
          DAT_004de23e       XREF[3]:     FUN_0041e270:0041e284(W),
                                          FUN_0041eef0:0041ef5a(W),
                                          FUN_004578f0:004579c0(W)
004de23e       ??         ??
          mapwho_checking
004de23f       ??         ??
          DAT_004de240       XREF[5]:     FUN_0042e2c0:0042e2eb(R),
                                          FUN_0042e2c0:0042e332(W),
                                          FUN_0042e2c0:0042e33c(RW),
                                          FUN_0042e2c0:0042e342(R),
                                          FUN_004392a0:004395ca(W)
004de240       undefined4 ??
          DAT_004de244       XREF[5]:     FUN_0042e2c0:0042e2c6(R),
                                          FUN_0042e2c0:0042e2de(W),
                                          FUN_0042e2c0:0042e363(R),
                                          FUN_0042e2c0:0042e4f4(R),
                                          FUN_0042e2c0:0042e506(R)
004de244       undefined4 ??
          plant_pagers
004de248       ??         ??
          nav_bits
004de249       ??         ??
          pixbuf_cells
004de24a       ??         ??
          object_cells
004de24b       ??         ??
004de24c       ??         ??
004de24d       ??         ??
004de24e       ??         ??
004de24f       ??         ??
          show_nav_cells     XREF[1]:     callback_enter_nav_debug:0041e17
004de250       ??         ??
          entry_cells
004de251       ??         ??
          keep_clear_cells
004de252       ??         ??
          machine_pagers
004de253       ??         ??
          display_room_status
004de254       ??         ??
          DAT_004de255       XREF[4]:     FUN_0045fa20:0045fa26(W),
                                          FUN_0045fa20:0045ffe5(RW),
                                          FUN_0045fa20:0045ffec(R),
                                          FUN_0045fa20:0045fff5(R)
004de255       undefined2 ??
          DAT_004de257       XREF[2]:     FUN_0045fa20:0045ffd9(R),
                                          FUN_0045fa20:0045fffb(W)
004de257       undefined2 ??
          display_big_cells
004de259       ??         ??
          DAT_004de25a       XREF[6]:     FUN_00403480:004035bc(R),
                                          FUN_0041a220:0041a32d(W),
                                          FUN_0041a220:0041a37b(W),
                                          FUN_0042bba0:0042bbdd(R),
                                          FUN_0042bba0:0042c20e(R),
                                          FUN_004392a0:004393b3(R)
004de25a       ??         ??
004de25b       ??         ??
004de25c       ??         ??
004de25d       ??         ??
004de25e       ??         ??
004de25f       ??         ??
004de260       ??         ??
004de261       ??         ??
          show_help_hotspots
004de262       ??         ??

Well, that doesn’t look like good news: some addresses are indeed referenced, but none for debug menu entries (we can probably rule out indirect accesses done from a base address + offset, since we have plenty of direct references). The only exception is show_nav_cells, which is automatically set when we select entry ENTER NAV DEBUG. This is handled by a function at that seemingly different address we saw earlier:

          callback_enter_nav_debug
0041e170      PUSH       0xd
0041e172      CALL       FUN_0042e7e0

0041e177      MOV        byte ptr [show_nav_cells],0x1

0041e17e      ADD        ESP,0x4
0041e181      RET

Let’s check the called function:

void __cdecl FUN_0042e7e0(byte param_1)

{
  DAT_004e11be = (ushort)param_1;
  if (false) {
switchD_0042e7ee_caseD_3:
    DAT_004de57b = 1;
  }
  else {
    switch(DAT_004e11be) {
    case 0:
      DAT_004de57b = 1;
      break;
    case 1:
    case 2:
    case 7:
      DAT_004de57b = 3;
      break;
    default:
      goto switchD_0042e7ee_caseD_3;
    case 9:
      DAT_004de57b = 0;
      break;
    case 10:
      DAT_004de57b = 0xd;
      break;
    case 0xb:
      DAT_004de57b = 0xe;
      break;
    case 0xc:
      DAT_004de57b = 4;
      break;
    case 0xe:
      DAT_004de57b = 9;
      break;
    case 0xf:
      DAT_004de57b = 0xc;
      break;
    case 0x10:
      DAT_004de57b = 9;
      break;
    case 0x12:
      DAT_004de57b = 0xf;
      break;
    case 0x14:
      DAT_004de57b = 0xf;
      break;
    case 0x15:
      DAT_004de57b = 1;
      break;
    case 0x16:
      DAT_004de57b = 0x1a;
      break;
    case 0x17:
      DAT_004de57b = 0x10;
      break;
    case 0x18:
      DAT_004de57b = 0xb;
      break;
    // ...
    case 0x28:
      DAT_004de57b = 0x1c;
      break;
    case 0x29:
      DAT_004de57b = 0x1e;
    }
  }
  if (DAT_004db19c != '\0') {
    DAT_004db19c = 0;
    return;
  }
  FUN_0042ecb0(DAT_004de57b);
  return;
}

Depending on the argument value, some memory position is set and another function gets called with it. I’ve ommited part of the switch case statement, but you can tell that some cases are missing, including one for 0xd that is passed by callback_enter_nav_debug().

Digging further into the calls, eventually we see some to SetRect(), so something using graphics.

If we break at the call to FUN_0042ecb0(), and change the stack value to some other value set in the switch case statement, we observe that this changes the cursor that is drawn. The default case uses the main cursor (i.e. the syringe). However, this also sets a “locked” cursor mode, where you can’t interact with menus and such (same behaviour when you e.g. pick a person to drop somewhere, and can’t check the queue for a door while holding that person). You have to press Esc to return to the “normal” cursor mode.

All this seems to suggest that a specific cursor mode existed for ENTER NAV DEBUG, but now it just defaults to this fallback mode.

Enumerating deadcode

If we were in the presence of indirect references, or even stripped out calls, the debug menu handler instructions could still be present in the code.

Starting by the memory addresses, we could check if they were stored somewhere else in the binary. I wrote a script to parse menu data structures, and for each found memory address, check if it had more than one occurrence in the binary (similar to IDA’s data xref). However, this didn’t give us any additional results. Note that DOS builds may return double the matches, but this applies to all menus.

At this point, I started to look at earlier versions of the game, and one of them stood out: the DOS Demo Beta v2.0 had Watcom debug symbols! So, if we could find deadcode functions, we would also have a good idea of what they did just by the function signature.

We can parse these symbols from the output of wdump -Dx -a HOSPITAL.EXE 2, then load them into our IDA database via IDC commands. 3

Afterwards, another script was used to find:

  1. Functions that had zero call xrefs;
  2. Undefined blocks of bytes between functions, which could then be dissassembled to instructions (catching functions that didn’t have a debug symbol to identify them).

Turns out that the former listing was more useful, as the latter listing generated much more false positives.

It’s hard to improve on this without accidentally cutting out a potentially relevant block. Consider the following match at the unstubbed executable’s address 0098633:

00098633 90                                      align 4
00098634 CA 8E 09 00             off_98634       dd offset loc_98ECA     ; DATA XREF: cseg01:00098EB1
00098638 CA 8E 09 00                             dd offset loc_98ECA
0009863C B9 8E 09 00                             dd offset loc_98EB9
; [...]
00098668 2F 90 09 00             off_98668       dd offset loc_9902F     ; DATA XREF: cseg01:00099016
0009866C 2F 90 09 00                             dd offset loc_9902F
00098670 1E 90 09 00                             dd offset loc_9901E
; [...]
000986B0                         ; void display_year_charts(void)
000986B0                         W?display_year_charts$n__v proc near    ; CODE XREF: game(void):loc_112B9
000986B0                                                                 ; game(void):loc_1141C...
000986B0 53                                      push    ebx
000986B1 51                                      push    ecx
000986B2 52                                      push    edx
000986B3 56                                      push    esi
000986B4 57                                      push    edi
000986B5 55                                      push    ebp
000986B6 81 EC B0 01 00 00                       sub     esp, 1B0h
000986BC 81 C4 B0 01 00 00                       add     esp, 1B0h
000986C2 5D                                      pop     ebp
000986C3 5F                                      pop     edi
000986C4 5E                                      pop     esi
000986C5 5A                                      pop     edx
000986C6 59                                      pop     ecx
000986C7 5B                                      pop     ebx
000986C8 C3                                      retn
000986C8                         W?display_year_charts$n__v endp
000986C8
000986C9                         ; ---------------------------------------------------------------------------
000986C9
000986C9                         loc_986C9:                              ; DATA XREF: cseg01:000986A0
000986C9 6A 0D                                   push    0Dh
000986CB BB 16 00 00 00                          mov     ebx, 16h
000986D0 BA 01 00 00 00                          mov     edx, 1
000986D5 30 E4                                   xor     ah, ah
000986D7 31 C9                                   xor     ecx, ecx
000986D9 88 25 24 86 15 00                       mov     W?current_display$nuc, ah ; char current_display
000986DF B8 0F 00 00 00                          mov     eax, 0Fh
000986E4 E8 0F 2D 00 00                          call    W?load_ingame_pic$n_xucxucxucxuc$TbScreenMode$$_v ; load_ingame_pic(char const,char const,char const,char const,TbScreenMode)
000986E9 30 D2                                   xor     dl, dl
000986EB 88 15 25 86 15 00                       mov     W?current_award$nuc, dl ; char current_award
000986F1 66 C7 05 86 D5 14 00 B0+                mov     word_14D586, 4B0h
000986FA C6 05 4A F9 14 00 03                    mov     byte_14F94A, 3
00098701 E9 9C 10 00 00                          jmp     loc_997A2
00098706                         ; ---------------------------------------------------------------------------
00098706
00098706                         loc_98706:                              ; DATA XREF: cseg01:0009869C
00098706 6A 0D                                   push    0Dh

Many matches occurred for an offset table used for jumps in switch-case statements, since these appeared before the function start address, which had the call xref.

Ok, so we can detect the align instruction followed by offsets, and ignore these blocks, right? Well, look closely: this function is referenced, but it’s actually a no-op. So the offset table should be considered an unreferenced block, along with those case blocks.

But this situation can also happen the other way around: a case block is detected as unreferenced, even though it is reachable from the previous function. This happens when you have indirect jumps to the next blocks, such as:

000436FC 2E FF 24 85 AC 36 04 00                 jmp     cs:off_436AC[eax*4]

In the end, there was a lot of unproductive manual verification of these blocks… 4

Anyway, let’s check some found unreferenced functions.

Cheats?

Why would you cheat in a demo? Seems to be the question developers asked, and their answer was:

000175F0       ; void cheat(unsigned long)
000175F0       W?cheat$n_ul_v  proc near  ; CODE XREF: process_keys(char,char)+78Bp
000175F0 C3    retn

If we follow that process_keys() reference, we can see some input logic that triggers this poor sad little no-op.

Fax machine

One of the references comes from function process_fax_buttons():

0008A0E7                 loc_8A0E7:       ; DATA XREF: cseg01:00089D40o
0008A0E7 B8 08 00 00 00           mov     eax, 8
0008A0EC BA AC 00 00 00           mov     edx, 0ACh ; '¼'
0008A0F1 E8 FA D4 F8 FF           call    W?cheat$n_ul_v  ; cheat(unsigned long)

The retail releases contain cheats activated on screens with a fax machine, by clicking on buttons following specific number sequences. In the demo, some of these sequences were different.

Let’s find them out, starting by following that data xref:

00089D3C CB A0 08 00              dd offset loc_8A0CB  ; DATA XREF: process_fax_buttons(char)+3F4r
00089D40 E7 A0 08 00              dd offset loc_8A0E7

Which is accessed by adding an offset to the switch-case table base address:

0008A16C                 loc_8A16C:       ; CODE XREF: process_fax_buttons(char)+31Cj
0008A16C                                  ; process_fax_buttons(char)+32Bj
0008A16C 31 C0                    xor     eax, eax
0008A16E 8A 44 24 0C              mov     al, [esp+10h+var_4]
0008A172 B3 01                    mov     bl, 1
0008A174 8A 04 C5 9F 23 14 00     mov     al, byte_14239F[eax*8]
0008A17B 88 5C 24 08              mov     [esp+10h+var_8], bl
0008A17F 3C 05                    cmp     al, 5
0008A181 77 DA                    ja      short loc_8A15D
0008A183 25 FF 00 00 00           and     eax, 0FFh
0008A188 2E FF 24 85 3C 9D 08 00  jmp     cs:off_89D3C[eax*4]

Keep following the code xrefs, and we see that pressed button values are compared against several sequences:

00142398     ; FaxNo cheat_numbers[]
00142398 C2  W?cheat_numbers$n__$FaxNo$$ db 0C2h ; - ; DATA XREF: process_fax_buttons(char)+2C5
00142398                                             ; process_fax_buttons(char)+2D4
00142399 C2                  db 0C2h ; -
0014239A C2                  db 0C2h ; -
0014239B 00                  db    0
0014239C 00                  db    0
0014239D 00                  db    0
0014239E 03                  db    3
0014239F 00  byte_14239F     db 0                    ; DATA XREF: process_fax_buttons(char)+3E0
001423A0 C2                  db 0C2h ; -
001423A1 BA                  db 0BAh ; ¦
001423A2 BA                  db 0BAh ; ¦
001423A3 00                  db    0
001423A4 00                  db    0
001423A5 00                  db    0
001423A6 03                  db    3
001423A7 01                  db    1
001423A8 C2                  db 0C2h ; -
001423A9 BA                  db 0BAh ; ¦
001423AA BB                  db 0BBh ; +
001423AB 00                  db    0
001423AC 00                  db    0
001423AD 00                  db    0
001423AE 03                  db    3
001423AF 02                  db    2
001423B0 BA                  db 0BAh ; ¦
001423B1 B9                  db 0B9h ; ¦
001423B2 B9                  db 0B9h ; ¦
001423B3 00                  db    0
001423B4 00                  db    0
001423B5 00                  db    0
001423B6 03                  db    3
001423B7 03                  db    3
001423B8 BF                  db 0BFh ; +
001423B9 BF                  db 0BFh ; +
001423BA BF                  db 0BFh ; +
001423BB 00                  db    0
001423BC 00                  db    0
001423BD 00                  db    0
001423BE 03                  db    3
001423BF 05                  db    5
001423C0 BF                  db 0BFh ; +
001423C1 BE                  db 0BEh ; +
001423C2 BF                  db 0BFh ; +
001423C3 BA                  db 0BAh ; ¦
001423C4 BB                  db 0BBh ; +
001423C5 B9                  db 0B9h ; ¦
001423C6 06                  db    6
001423C7 04                  db    4
001423C8 00                  db    0
001423C9 00                  db    0
001423CA 00                  db    0
001423CB 00                  db    0
001423CC 00                  db    0
001423CD 00  unk_1423CD      db    0                 ; DATA XREF: refresh_to_VRAM(void)+261
001423CE 00                  db    0
001423CF 00                  db    0

We have an array of FaxNo, an 8 byte sized structure: 6 bytes for the code values, 1 byte for the code length, and 1 byte that appears to be some index.

These values aren’t ascii chars, nor scan codes. So the easiest way to convert them is to place a breakpoint in one of the button value comparisons, press one button at a time, and check the value in the DOSBox debugger.

Where should we place the breakpoint? Let’s check the function’s initial block:

00089D94                         ; void process_fax_buttons(char)
00089D94                         W?process_fax_buttons$n_uc_v proc near  ; DATA XREF: dseg03:0013EEC0o
00089D94                                                                 ; dseg03:0013EED2o ...
00089D94
00089D94                         var_10          = byte ptr -10h
00089D94                         var_A           = byte ptr -0Ah
00089D94                         var_8           = byte ptr -8
00089D94                         var_4           = byte ptr -4
00089D94
00089D94 53                                      push    ebx
00089D95 51                                      push    ecx
00089D96 52                                      push    edx
00089D97 56                                      push    esi
00089D98 57                                      push    edi
00089D99 55                                      push    ebp
00089D9A 83 EC 10                                sub     esp, 10h
00089D9D 88 C4                                   mov     ah, al
00089D9F 80 EC B8                                sub     ah, 0B8h ; '+'
00089DA2 80 FC 0F                                cmp     ah, 0Fh
00089DA5 0F 87 45 04 00 00                       ja      sub_8A1F0
00089DAB 31 D2                                   xor     edx, edx
00089DAD 88 E2                                   mov     dl, ah
00089DAF 2E FF 24 95 54 9D 08 00                 jmp     cs:off_89D54[edx*4]

It takes a single argument, and substracts B8h from it (note that in the Watcom x86 calling convention, the first argument is passed in eax). All the number sequences seem to contain bytes higher than B8h or equal to 0, and the result from subtraction seems like it would fall inside a range of button indexes (the machine has 13 buttons), so it’s likely that ah contains the value to be compared against.

We should also place breakpoints in each block leading to a different cheat. There’s another call to cheat(), and also a block that modifies some cheat mode, so that’s 3 places to place breakpoints (0008A0E7, 0008A10D, 0008A133), in addition to the one in the initial block (00089D9D). Note that you have to use the address translation described in a previous writeup.

After some experimentation, we get the following codes and corresponding actions:

Code Action Index Action Behaviour
999 0 N/A
911 1 Calls cheat(08h), does nothing.
912 2 Calls cheat(10h), does nothing.
100 3 N/A
666 5 N/A
656120 (ascii encoded “ea “, a reference to Bullfrog’s parent company?) 4 Toggles CheatMode, read in display_choice(), which draws “(!)” at the top-left area of the screen when set. Same behaviour as the unused function display_cheat_mode().

Here’s the visual feedback for code 656120:

One curious detail: all codes have their own action index, which is used to jump to a case block. In Beta 5, all no-op actions share the same index, so probably there were more cheats initially planned.

To find the file offsets for that build, we already know one of the codes (24328), and assuming the data structure is similar, all we need is to match bytes with a difference in value equal to the difference of code digits.

Here are those codes for comparison (usually the no-op codes aren’t listed in cheat sites, but they also have the sound feedback when activated):

Code Action Index Action Behaviour
999 1 N/A
911 1 N/A, was cheat(08h)
912 1 N/A, was cheat(10h)
100 1 N/A
111 1 N/A
222 1 N/A
333 1 N/A
444 1 N/A
555 1 N/A
666 1 N/A
777 1 N/A
888 1 N/A
999 (2nd occurrence) 1 N/A
7287 (CP-1252 encoded “r‡”, r for rat, dagger for kill?) 2 Activates bonus level
24328 0 Enables in-game cheat keybinds

Tilts

Other cheat() calls occur in process_keys(), with a certain string loaded before them, such as:

00061F46                         loc_61F46:                              ; CODE XREF: process_keys(char,char)+770j
00061F46 A8 01                                   test    al, 1
00061F48 74 46                                   jz      short loc_61F90
00061F4A B8 08 00 00 00                          mov     eax, 8
00061F4F BE 53 06 11 00                          mov     esi, offset aMoneyTilt ; "MONEY TILT!"
00061F54 BF FA E1 14 00                          mov     edi, offset unk_14E1FA
00061F59 B7 C8                                   mov     bh, 0C8h ; '+'
00061F5B E8 90 56 FB FF                          call    W?cheat$n_ul_v  ; cheat(unsigned long)

However, this string loading pattern also occurs without cheat() calls:

 call    W?create_inspector$n_uc_us ; create_inspector(char)
00087EA9 8B 1D A0 BF 14 00                       mov     ebx, W?allhosps$npn$AllHosps$$ ; AllHosps *allhosps
00087EAF BE 84 1D 11 00                          mov     esi, offset aInspectorGener ; "Inspector Generated"
00087EB4 BF FA E1 14 00                          mov     edi, offset unk_14E1FA
00087EB9 66 89 84 1A 01 02 00 00                 mov     [edx+ebx+201h], ax
00087EC1 57                                      push    edi

Which is always followed by a string copy and byte_14E2C2=0C8h:

00087EC2                         loc_87EC2:                              ; CODE XREF: generate_inspector(char)+58
00087EC2 8A 06                                   mov     al, [esi]
00087EC4 88 07                                   mov     [edi], al
00087EC6 3C 00                                   cmp     al, 0
00087EC8 74 10                                   jz      short loc_87EDA
00087ECA 8A 46 01                                mov     al, [esi+1]
00087ECD 83 C6 02                                add     esi, 2
00087ED0 88 47 01                                mov     [edi+1], al
00087ED3 83 C7 02                                add     edi, 2
00087ED6 3C 00                                   cmp     al, 0
00087ED8 75 E8                                   jnz     short loc_87EC2
00087EDA
00087EDA                         loc_87EDA:                              ; CODE XREF: generate_inspector(char)+48
00087EDA 5F                                      pop     edi
00087EDB C6 05 C2 E2 14 00 C8                    mov     byte_14E2C2, 0C8h ; '+'

And then… nothing reads it? unk_14E1FA is also written to in debug_msg() calls:

00078710                         ; void debug_msg(char *,...)
00078710                         W?debug_msg$n_pnae_v proc near          ; CODE XREF: save_player_details(void)+3D
00078710                                                                 ; load_player_details(void)+E9...
00078710
00078710                         var_4           = dword ptr -4
00078710                         arg_0           = dword ptr  0Ch
00078710                         arg_4           = byte ptr  10h
00078710
00078710 53                                      push    ebx
00078711 52                                      push    edx
00078712 83 EC 04                                sub     esp, 4
00078715 8D 5C 24 14                             lea     ebx, [esp+4+arg_4]
00078719 8B 54 24 10                             mov     edx, [esp+4+arg_0]
0007871D B8 FA E1 14 00                          mov     eax, offset unk_14E1FA
00078722 89 1C 24                                mov     [esp+4+var_4], ebx
00078725 89 E3                                   mov     ebx, esp
00078727 E8 C7 7F 03 00                          call    vsprintf_
0007872C 31 D2                                   xor     edx, edx
0007872E B4 C8                                   mov     ah, 0C8h ; '+'
00078730 89 14 24                                mov     [esp+4+var_4], edx
00078733 88 25 C2 E2 14 00                       mov     byte_14E2C2, ah

So a fair guess is that unk_14E1FA would be read in some logging subroutine.


Going back to the cheats, how are these activated? Well, the approach will be very similar to finding out the values when clicking buttons for the fax machine cheats, but since the function signature is void process_keys(char,char), let’s see the caller context, to understand where one of the arguments comes from:

00011156 A0 B4 F8 14 00                          mov     al, W?HospShift$nuc ; char HospShift
0001115B E8 E0 05 05 00                          call    W?process_keys$n_ucuc_v ; process_keys(char,char)

HospShift is written in get_input():

00010CB0                         ; void get_input(void)
00010CB0                         W?get_input$n__v proc near              ; CODE XREF: game(void)+80p
00010CB0                                                                 ; cseg01:0007197Bp ...
00010CB0 52                                      push    edx
00010CB1 30 D2                                   xor     dl, dl
00010CB3 80 3D 6A 5A 18 00 00                    cmp     byte_185A6A, 0
00010CBA 75 09                                   jnz     short loc_10CC5
00010CBC 80 3D 76 5A 18 00 00                    cmp     byte_185A76, 0
00010CC3 74 03                                   jz      short loc_10CC8
00010CC5
00010CC5                         loc_10CC5:                              ; CODE XREF: get_input(void)+Aj
00010CC5 80 CA 01                                or      dl, 1
00010CC8
00010CC8                         loc_10CC8:                              ; CODE XREF: get_input(void)+13j
00010CC8 80 3D 78 5A 18 00 00                    cmp     byte_185A78, 0
00010CCF 75 09                                   jnz     short loc_10CDA
00010CD1 80 3D F8 5A 18 00 00                    cmp     byte_185AF8, 0
00010CD8 74 03                                   jz      short loc_10CDD
00010CDA
00010CDA                         loc_10CDA:                              ; CODE XREF: get_input(void)+1Fj
00010CDA 80 CA 04                                or      dl, 4
00010CDD
00010CDD                         loc_10CDD:                              ; CODE XREF: get_input(void)+28j
00010CDD 80 3D 5D 5A 18 00 00                    cmp     byte_185A5D, 0
00010CE4 75 09                                   jnz     short loc_10CEF
00010CE6 80 3D DD 5A 18 00 00                    cmp     byte_185ADD, 0
00010CED 74 03                                   jz      short loc_10CF2
00010CEF
00010CEF                         loc_10CEF:                              ; CODE XREF: get_input(void)+34j
00010CEF 80 CA 02                                or      dl, 2
00010CF2
00010CF2                         loc_10CF2:                              ; CODE XREF: get_input(void)+3Dj
00010CF2 88 15 B4 F8 14 00                       mov     W?HospShift$nuc, dl ; char HospShift
00010CF8 5A                                      pop     edx
00010CF9 C3                                      retn

However, those memory values being compared against don’t have write references. We can just place breakpoints after some comparisons to see which are triggered when we type. After some experimentation, we realize that this is tracking which modifier keys were pressed (i.e. Alt, Ctrl, Shift).

These are the strings that get loaded for certain keybinds:

Keybind String HospShift bits
E Editor not compiled-in 000
F Compled editor not compiled-in 000
D TILT! 000
Shift-C MONEY TILT! 001
Ctrl-C OBJECTS TILT! 010
Ctrl-Shift-C OBJECTS POWER TILT! 011

Two of these probably made their way to the retail releases, since both keybinds and corresponding strings match the behaviours for the Beta 5 fax cheat code 24328:

Keybind Beta v2.0 String Beta 5 Action Behaviour
Shift-C MONEY TILT! Increase bank balance by $10,000
Ctrl-Shift-C OBJECTS POWER TILT! Gain all rooms and equipment

Rats

Function process_game_keys() appears to be largely identical to the previously seen process_keys(), although there are some differences in parsed keybinds: There’s none for invoking editors, and there’s an additional one that calls make_a_rat().

To try it out, let’s replace the call address from process_keys() to process_game_keys() in game(). Recall that these are encoded in instructions as relative addresses, given by expression target address - next instruction address after call in two’s complement. Consider these calls:

0001115B E8 E0 05 05 00    call    W?process_keys$n_ucuc_v ; process_keys(char,char)
00011160 B8 8C 77 15 00    mov     eax, offset W?open_windows$n__pn$Window$$ ; Window *open_windows[]
[...]
00011219 E8 22 05 05 00    call    W?process_keys$n_ucuc_v ; process_keys(char,char)
0001121E E8 B1 75 07 00    call    W?process_sound_heap$n__v ; process_sound_heap(void)

The instruction at 0x11219 becomes E8 86 11 05 00, since 0x623a4 - 0x1121e = 0x51186.

To find the file offset to patch these bytes (since the disassembly is for the unstubbed executable, but we want to patch the stubbed one), we can take a chunk of the next instructions, and search for those bytes:

binwalk -R '\xE8\x22\x05\x05\x00\xE8\xB1\x75\x07\x00\xE8\xDC\x14\x07\x00\xE8\x9B\x78\x00\x00' HOSPITAL.EXE
# 0x59219

Using the same approach for the other call, we patch:

  • 0x59219 = 0xE8861105
  • 0x5915B = 0xE8441205

After placing a breakpoint in the call to make_a_rat() from process_game_keys(), we get a hit with keybind Shift-R.

But there’s a catch: without two calls to try_create_rathole(), rats won’t spawn:

000686CB 0F B6 B4 02 AE 0C 00 00  movzx   esi, byte ptr [edx+eax+0CAEh]
000686D3 83 FE 02                 cmp     esi, 2          ; at least two ratholes created?
000686D6 0F 82 7D 01 00 00        jb      end_make_a_rat

We can force a direct call to that function by replacing one of the cheat() call’s relative address from process_game_keys() (e.g. the one activated by Shift-C), and pass eax=0 (seems to be a valid candidate in other calls of try_create_rathole()):

  • 0xAA92A = 0x0
  • 0xAA93A = 0xE8F1580000

Then, we place a breakpoint in try_create_rathole(), press Shift-C two times, and make the following manual memory updates:

  • For rathole #1:
    • At 00068266 and al, 3, run sr eax 3
    • At 00068360 mov dl, [ebx+7], run memdumpbin ds:ebx 20000, and pick an address where ebx+05=79 and ebx+07=01 (e.g. 485a78), then run sr ebx 485a78
  • For rathole #2:
    • At 00068266 and al, 3, run sr eax 0
    • At 000682B0 mov dl, [ebx+7], pick an address where ebx+05=00 and ebx+07=01 (e.g. 485aa8), then run sr ebx 485aa8

These ebx values are checked in 4 branches of a switch-case statement in try_create_rathole(), one for each Map cell side. We are picking one of these branches at 00068266, then we are forcing a cell to be picked from the Map data structure, where we want to place the rathole. To figure out which values we wanted for ebx, we can just check which ones in the memory dump pass the conditions in the switch-case branches, avoiding an early return from the function:

00068256 8B 1D 58 B7 14 00  mov     ebx, W?map$npn$Map$$ ; Map *map
[...]
000682AB B8 01 00 00 00     mov     eax, 1
000682B0 8A 53 07           mov     dl, [ebx+7]  ; should be ebx+07=01
000682B3 D3 E0              shl     eax, cl
000682B5 39 C2              cmp     edx, eax
000682B7 0F 85 D1 01 00 00  jnz     end_try_create_rathole

The address alignment is figured out by checking how the normal execution moves to the next cell, in case a rathole can’t be placed in the current candidate cell:

000682E1            next_map_area_step:  ; CODE XREF: try_create_rathole(char)+CFj
000682E1                    ; try_create_rathole(char)+E1j
000682E1 83 C3 08           add     ebx, 8

With these two new ratholes set in the two Map cells (00485A78+6 |= 8 and 00485AA8+6 |= 7):

0168:00485A78 00 00 41 78 79 10 38 01 00 00 41 7A 00 00 00 01
0168:00485A88 00 00 41 7C 00 00 00 01 00 00 41 7A 00 00 00 01
0168:00485A98 00 00 41 7C 00 00 00 01 00 00 41 78 00 00 00 01
0168:00485AA8 00 00 41 78 00 00 07 01 00 00 41 78 00 00 00 01
0168:00485AB8 00 00 41 7A 00 00 00 01 00 00 41 7C 00 00 00 01
0168:00485AC8 00 00 41 7A 00 00 00 01 00 00 41 7C 00 00 00 01
0168:00485AD8 00 00 41 78 00 10 00 01 00 00 08 00 71 01 60 11
0168:00485AE8 00 00 00 00 00 01 00 10 00 00 02 00 00 01 00 10

We are now able to activate the cheat:

Debug leftovers

debug_win_game() & debug_win_level()

These seem like possible fits for the debug menu entries WIN GAME ANIM and WIN LEVEL ANIM. debug_win_game() sets the current level to be the last one, then uses the same cheat code as debug_win_level():

00075EC4  ; void debug_win_game(void)
00075EC4  W?debug_win_game$n__v proc near
00075EC4      mov     W?level_number$nuc, 0Ch  ; char level_number
00075ECB      nop
00075ECB  W?debug_win_game$n__v endp
00075ECC
00075ECC  ; void debug_win_level(void)
00075ECC  W?debug_win_level$n__v proc near
00075ECC      mov     eax, 2000h
00075ED1      jmp     W?cheat$n_ul_v  ; cheat(unsigned long)
00075ED1  W?debug_win_level$n__v endp

This is what we named as callback_enter_nav_debug() for Beta 5.

In function process_things(), there are several calls to actions that can be performed by a given Thing, depending on its type (e.g. staff, patient…).

One of those actions is nav_debug_state(), which apparently is unused. We can force a call by changing the relative address of one of the other used ones (e.g. staff_wanders()), just like we did for process_game_keys(). The passed Thing will do the “turn around” animation for a bit:

00014A64  ; void nav_debug_state(Thing *)
00014A64  W?nav_debug_state$n_pn$Thing$$_v proc near
00014A64          ; CODE XREF: process_things(Thing *,Thing *)+1D3p
[...]
00014A68  mov     ebx, eax
[...]
00014A8C  mov     al, W?next_angle$n__s[eax*2] ; short next_angle[]
00014A93  mov     dl, [ebx+0Eh]
00014A96  mov     cl, al
00014A98  mov     [ebx+11h], al ; store next angle for current Thing

But a specific field in the Thing instance (ebx+5Eh) is always 0:

00014B57  mov     al, [ebx+5Eh]
00014B5A  mov     [ebx+0Dh], al
00014B5D  mov     eax, ebx
00014B5F  call    W?nav_init$n_pn$Thing$$_v ; nav_init(Thing *)

This leads to an invalid state with no animation (the person is still there, since you can’t place a room covering the person’s position):

Each action handler is jumped into from a huge switch-case statement. When nav_debug_state() is called, the next case will use that field value, but when it’s 0, it doesn’t match any of the defined cases, and the subroutine advances to the next Thing:

00049B23  skip_thing:
00049B23  add     ebx, 0AFh        ; point to next thing
00049B29  cmp     ebx, esi         ; are there more things to process?
00049B2B  jnb     goto_final_count
00049B31  jmp     short loc_49AF6  ; check current thing

Note that a subsequent action can be automatically assigned to the person, thus updating that field value. For example, if we replace person_arrives_at_door(), we lose the “knock at the door” step, but the person still enters the room and carries on:

How about trying other actions besides 0? Before being set at nav_debug_state(), the previous value at ebx+0Dh is 4. If we break right after the assignment, and set the memory value of the Thing* action field to 2, the person remains static, but with 4 (person_waits_for_busy()), the person continues turning around, but indefinately:

If we set to 12h (staff_goto_object()):

The sliding effect seems to be an artifact of trying to reach an object that coincides with the person’s position. Which makes it clear that the path was generated without an explicit destination. Some additional logic must set that…

Let’s recall nav_debug_mode(), the handler for the debug menu entry ENTER NAV DEBUG. Perhaps it could have contained that destination logic: in a build where the missing pointer type was present, the user could select a person and click on some cell to set the destination, which would be managed by nav_debug_state(). Of course, this is just speculation, but it seems like a plausible theory.

TODO

  • Apply symbols to other builds, using some bindiff algorithm;
  • Confirm there aren’t other dead code snippets related to the debug menu. As mentioned, the manual process was error-prone, so who knows…
  1. In DOS/Windows Demo Beta v2.0, the “Display” menu is also disabled, but can be patched-in with the same approach. The menu entry that toggles shadows works as expected, but lo-res mode crashes the game… maybe that’s why they disabled that one? 

  2. I’ve also tried wcdctool, but it throwed errors parsing this executable. Nevertheless, I used its OpenWatcom build script to get wdump. 

  3. Since the last time I dealt with a Linear Executable, I checked if there was a way to benefit from the comfort of a decompiler, and indeed it is possible, since radare2 has a LX/LE loader, and also integrates with the Ghidra decompiler. If you want a GUI (with the linked graph + decompiler views when you select a given address), you can use Cutter, which is based on the fork rizin. Then you have to take the wdump output and translate that to radare2 commands. However, the decompilation will return bad instructions for the switch-case statements, so its of limited use. Furthermore, I got lazy and sticked with IDA since it automatically demangled the Watcom symbols and conveniently inserted them as comments, but it should be possible to take the demangling code from OpenWatcom and extend the radare2 script with it. 

  4. Perhaps getting some measure of relevance to sort by could help: e.g. does it have calls to graphics APIs in its call graph? Then it’s more relevant. Getting code coverage via symbolic execution could be used to trim down unreachable false positives. Yes, this part is intentionally handwavy…