A byte too far
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:
- run in dxwnd
- attach x64dbg
- 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:
- Functions that had zero call xrefs;
- 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
, runsr eax 3
- At
00068360 mov dl, [ebx+7]
, runmemdumpbin ds:ebx 20000
, and pick an address whereebx+05=79 and ebx+07=01 (e.g. 485a78)
, then runsr ebx 485a78
- At
- For rathole #2:
- At
00068266 and al, 3
, runsr eax 0
- At
000682B0 mov dl, [ebx+7]
, pick an address whereebx+05=00 and ebx+07=01 (e.g. 485aa8)
, then runsr ebx 485aa8
- At
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
nav_debug_mode()
This is what we named as callback_enter_nav_debug()
for Beta 5.
nav_debug_state()
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…
-
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? ↩
-
I’ve also tried wcdctool, but it throwed errors parsing this executable. Nevertheless, I used its OpenWatcom build script to get wdump. ↩
-
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. ↩
-
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… ↩