The Anger of Pharaoh XXXV, reverse engineered
September 3, 2026 · AI
Yesterday I handed a 74 KB DOS executable from 1997 to Claude Code and asked a simple question: can you make this run in a browser? No source code, no emulator, just the zip. Then I sat back and watched.
The executable is The Anger of Pharaoh XXXV, a text adventure a friend and I wrote in November 1996 for the Data Structures course; an archaeologist lost in a pyramid, six rooms, a parser that understood TAKE and USE … WITH, and jokes that only two Greek students would find funny (the credits in the death screens say "Mike & Bill", and I am the Bill). It has been sitting on the software page as a zip since the late 90s. The source is gone; I do not remember when exactly (a 3.5" floppy, a hard disk that died, one of the usual stories). What survived is GAME.EXE and nine data files.
I want to be clear about one thing before going on: I did not write a line of this. The model did the whole job, in one afternoon, and it worked. That is not the interesting part, though. The interesting part is how it went about it, because the order it chose is the order I would teach, and it found that order on its own. So this is a post about its methods, written from the notes it kept as it went (I asked for those too; they are in the repository, and every command and output below is copied from them).
Sizes first
It did not open a disassembler. It did not open a hex editor either. The first thing it did was list the file sizes and do arithmetic on them:
game.exe 74,516 MS-DOS executable game.dat 463,479 "data" char.dat 9,100 begin.gfx 32,008 end.gfx 32,008 end1.gfx 11,488 (end2..end5 identical size)
32,008 is 320 × 100 + 8. So begin.gfx is a 320×100 picture, one byte per pixel, with an 8-byte header, which means VGA mode 13h (256 colours) rather than the 16-colour EGA (Enhanced Graphics Adapter) it had first assumed. 11,488 = 164 × 70 + 8 is another picture. And 9,100 = 91 × 100 makes char.dat a font: 91 glyphs, ASCII 32 to 122, each a (12, 8) header plus 96 pixel bytes. One xxd to confirm:
$ xxd -l 16 begin.gfx 00000000: 6400 4001 0000 1400 feff feff feff feff d.@.............
Four little-endian words: 100, 320, 0, 20. Height, width, x, y. It had the picture format and the font before it had touched a single byte of code. I have done this myself with file formats many times, and I have also watched people skip it and go straight to the disassembly. It did not skip it.
The gift of Borland
Then strings -n 4 game.exe, which its notes call "the single most productive command of the day", and I agree. It found "Borland C++ - Copyright 1994 Borland Intl." (so Borland C++ 4, real mode), and then the whole personality of the program:
Stop playing with that...
This is a game not a piano...stop it!
COMM_PTR too long for object %d
Data file error: Reference to non existing internal function %u
Commands size %ld offset %ld pallete %ld OBJ_NO %d SCR_NO %d OBJ_PTR %ld
OBJ no %d, size %ld,offset in file %ld and SCR:%d FLG:%d CDT:%d
Procedure no: %d and data %x,%x,%x,%x
Those are debug printfs that a 20-year-old (that would be me) forgot to remove, and they were a gift. They name the fields of the file header, the per-object runtime table (SCR, FLG, CDT), and the shape of a script entry. The model treated them as a map, and with that map the header parser was ten lines of Python:
d = open('game.dat', 'rb').read()
cmdsize, cmdoff, paloff = struct.unpack_from('<III', d, 2)
n1, n2 = struct.unpack_from('<HH', d, 14)
objptr, = struct.unpack_from('<I', d, 18)
# -> cmdsize 203 cmdoff 193678 paloff 192508 n1 6 n2 67 objptr 193276
Six screens, 67 objects, and a 203-byte verb table: synonyms separated by NUL, groups by an empty string, 0xFF at the end, and a 0xFE inside the two-part verbs (USE\xfeWITH) where the second object goes. Its notes say this is "the kind of format a student invents in an evening", which is exactly what happened, and I am not offended :)
One detail I liked: the palette had values up to 255 when VGA wants 6-bit. The lazy conclusion is that the file is wrong. It did not conclude; it left the question open and later found an idiv bx with bx = 4 in a loop of 256 in the disassembly. The file was right, the engine divides.
Pictures before code
Before reading any code, it rendered every picture to PNG with Pillow. This is the piece of advice I would give to anyone doing this by hand: render early. If the palette and the layout are right you see a room; if not, you see noise, and noise is a very honest test. It saw six rooms, a sphinx wearing glasses, a dancing mummy in a doorway, and five red-on-black death messages, my favourite being "Save early Save often. If only we had a save capability".
The script, and its one wrong guess
Each object record has a header, the NUL-separated names, the sprites, and a script block of verb blocks, each a list of (procedure, data) pairs. The model dumped all of them and looked for patterns. Codes were almost always 30 or 31, sometimes 20, 7, 3 or 6. Block ids were 1, 2, 3, 4, 5, 8.
Its first reading was "the id is the object's state and the code is the verb". Exactly backwards. What I found instructive is how it caught the mistake: not by reasoning harder but by testing the hypothesis against data. The torch's block 1 said "You can see a torch burning", block 4 said "Nothing special about the torch", block 8 said you try to walk through the wall and it "burns some of your pretty hair". Those are LOOK, EXAMINE and GO. So the id is the verb, and the codes are procedure numbers: 20 prints, 30 and 31 are "if state equals" and "if state differs". After that, the dump read like source:
=== obj 1 'EYE'
verb TAKE
30: {'obj': 1, 'val': 1, 'do': [(20, ' You already have it...|')]}
30: {'obj': 1, 'val': 2, 'do': [(20, ' You try to move it but it is stuck|in the wall, forget it|')]}
30: {'obj': 1, 'val': 0, 'do': [(20, ' You scratch floor, and the painted rock|...gets out.|'), (3, (1, 1)), (5, (1, 2))]}
And here is the sentence from its notes that I would frame: "Guesses are fine for reading; they are not fine for a clone." The meaning of procedures 3 and 5 was a guess at this point, and it refused to build on a guess. That is what the disassembly was for.
Reading the disassembly
It wanted Ghidra for the decompiler. The Homebrew cask no longer exists, and (its words) "downloading 400 MB for a 74 KB binary felt wrong", so it settled for Capstone plus grep. From the MZ header's relocation table it worked out which segments exist: segment 0 the Borland run-time library, 07bb the game's code, 0e68 the data segment. Knowing the data segment, a string's file offset becomes the immediate a push would carry, so three strings gave it three anchors: the loader, the command executor, and the options menu.
The one thing grep could not find was the procedure dispatcher, because it is not a switch:
07bb:19f2 mov bx, word ptr [bp - 0xa] ; procedure number 07bb:19f5 dec bx 07bb:19f6 shl bx, 2 07bb:19f9 lcall [bx + 0x3fe] ; far call through a table at DS:03FE
A table of far function pointers, filled by an init routine with seventeen mov pairs. It sorted them by slot, got the list of procedures, and then read all seventeen functions. Was any of this hard? Not really, and I think it is important to say so. Borland's code generator is verbose but regular, the program is small, and the debug strings did half the work. What impressed me was not brilliance; it was discipline. It read only the functions the data could not explain, and it stopped there.
The parser is where a clone lives or dies, because people type arbitrary things. The original picks the longest verb synonym that is a prefix of the line and is followed by a space, then the longest object name among the loaded objects. That trailing-space rule is why a bare LOOK fails with "What exactly is the second word?" while LOOK AROUND works. The model reproduced the rule exactly and wrote down why: improving it would change which sentences the puzzles accept. I would have been tempted to fix it. It was not.
The clone
The extractor writes one JSON bundle (palette, verbs, font, 65 pictures, 67 objects with their scripts decoded), 55 KB deflated. The engine keeps the original's shape on purpose: a 64,000-byte framebuffer with a 6-bit palette, the same opaque blit, the same seven text lines scrolling inside rows 109 to 199, the same key codes. Every function carries the original's offset in a comment, so a doubt is settled by reading the disassembly instead of arguing with the code. It changed two things knowingly (quitting returns to the title, since there is no DOS to return to, and the palette fade is driven by elapsed time instead of the vertical retrace) and it wrote both down.
What went wrong? Three things, and they are the same three that go wrong for humans. The first build could not parse USE EYE WITH PAINTING, because the page was served without a charset and the 0xFE marker became two Latin-1 characters. Then it lost twenty minutes chasing a "bug" that was its own test helper pushing an Enter with no ASCII code (its browser tooling does not synthesise real keypresses, so it drove the game through an exported key queue). Its note on that: "log what the program sees, not what you think you sent". And the sprites: the original compares sprite pointers to decide whether to redraw, two objects share one sprite, and the extractor had made two images of it. It deduplicated by offset and the behaviour came back.
Epilogue
About 1,350 lines of Python and JavaScript, a few thousand lines of disassembly read by eye, no emulator, and it never ran the original. The order it chose was: sizes and strings first, then data before code, then render early, then disassemble only what the data cannot explain. That is the right order. I did not tell it the order.
So what did I do? I asked the question, I read the notes, I played the game, and I checked the walkthrough against my memory of 1996 (it holds). I have written before that I am sceptical about what these tools deliver in day-to-day engineering, and I still am. But a small, closed, well-specified problem with a binary oracle at the end ("does the game play?") is, it seems, exactly the shape of problem they are good at. It also helps that a 20-year-old left the printfs in.
The game is playable in the browser (the walkthrough is there too, under a spoiler), and the engine, the extractor, the analysis scripts and the notes are on GitHub, in bkarak/pharaoh-xxxv. Every other branch of the last dialogue kills you, and you will find out how :)
There is one more 16-bit zip in that list, Quoter. We'll see.