Faithful High Resolution Textures

I discovered a while ago that some games companies charge extra for “high resolution textures” for their games. This phrase stuck with me until it finally found an outlet: it obviously needs to be rendered in glorious 4-color CGA as a puzzle solution to the 1987 DOS implementation of the popular American game show, “Wheel of Fortune”.

Wheel of Fortune begins its 39th season on September 13, 2021. I watched it frequently growing up, both live and in re-runs on the Game Show Network. The game is played with three players, a giant roulette-style wheel, and a secret English phrase. Players take turns spinning the wheel, choosing letters to reveal, and win a round by guessing the secret phrase correctly.

The 1987 DOS version of Wheel of Fortune can be played for free on archive.org. I also grew up with this implementation and its square-wave rendition of the 80s-era theme song “Changing Keys”, which is a Grade A cognitohazard earworm.

Wheel of Fortune is the ugliest computer game I have fond memories of playing (particularly the animation of the host’s low-res high heels clicking across the stage), and the combination of this memory and its phrase-based gameplay made it the perfect expression of “high resolution textures”.

My course of action was obvious:

  • obtain a copy of the 1987 DOS version of Wheel of Fortune
  • figure out its file format for puzzle solutions
  • coerce it into using “high resolution textures” as a puzzle solution
  • run the game with this puzzle solution and capture a screenshot
  • transform the screenshot into a cross-stitch pattern
  • make the pattern into a throw pillow
  • enjoy the feeling of victory over the games industry I feel by having distilled this phrase into something punchable

Get the Game

We’ll say for the purposes of this article that I dug up the original 5.25" floppies I played Wheel of Fortune on as a youth, asked someone with a working floppy drive to read them onto something modern for me, and transferred those files to something modern. That’s definitely what I did.

The results of doing so are something like:

user@emulators:~/wheel_of_fortune/wheel-of-fortune$ ls -alh
total 356K
drwxr-xr-x 2 user user 4.0K Sep 11 16:49 .
drwxr-xr-x 4 user user 4.0K Aug 25 13:00 ..
-rw-r--r-- 1 user user  52K Nov 16  1999 Answers.dat
-rw-r--r-- 1 user user 2.7K May  2  1987 Champs.dat
-rw-r--r-- 1 user user  162 Nov 16  1999 Clist.dat
-rw-r--r-- 1 user user  14K May  4  1987 Girl.dat
-rw-r--r-- 1 user user  60K May  7  1987 Menu.exe
-rw-r--r-- 1 user user  27K May  4  1987 Values.dat
-rw-r--r-- 1 user user 183K May  4  1987 Wheels.dat

Figure Out the File Format

Menu.exe is the game itself. Answers.dat is the file of interest, from which Wheel of Fortune loads a list of potential secret phrases for the game. In order to get our screenshot, one needs to figure out how to inject high resolution textures into Answers.dat. So what’s Answers.dat look like?

user@emulators:~/wheel_of_fortune/wheel-of-fortune$ xxd Answers.dat |head -20
00000000: f003 ffff ffff ffff ffff ffff ffff ffff  ................
00000010: ffff ffff ffff ffff ffff ffff ffff ff8f  ................
00000020: ff0f 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000c0: 0000 0000 0000 0000 0f6a 6a6a 6a6a 6a6a  .........jjjjjjj
000000d0: 6a6a 6a6a 6a41 252e 2f46 3541 332e 222a  jjjjjA%./F5A3."*
000000e0: 4141 4b4b 3f23 2e4b 2924 2a3f 4b4b 4b45  AAKK?#.K)$*?KKKE
000000f0: 4545 4545 4545 4545 4545 4545 0208 061f  EEEEEEEEEEEE....
00000100: 0f6a 0918 0519 196a 6a41 4120 2f25 4123  .j.....jjAA /%A#
00000110: 2d34 2441 4141 4b4b 4b38 2322 2e27 2f4b  -4$AAAKKK8#".'/K
00000120: 4b4b 4b45 4545 4545 4545 4545 4545 4545  KKKEEEEEEEEEEEEE
00000130: 0b6a 6a6a 6a6a 6a6a 6a6a 6a6a 6a41 412a  .jjjjjjjjjjjjAA*

Well, those aren’t plaintext strings. :/

Luckily, dosbox has a super helpful debugger. Running the game, pausing it when the phrase has been displayed, and running memdumpbin gives a greppable view of the program’s memory, revealing the plaintext:

000284c0: 0000 0000 0000 0000 0100 2020 2020 2020  ..........
000284d0: 2020 2020 2020 2020 4652 454e 4348 2041          FRENCH A
000284e0: 4e44 2020 2049 4e44 4941 4e20 5741 5220  ND   INDIAN WAR
000284f0: 2020 2020 2020 2020 2020 2020 2020 0000                ..
00028500: 0100 0f01 0000 0000 0000 5353 5353 5353  ..........SSSSSS

So somewhere in Answers.dat, there’s a phrase corresponding to FRENCH AND INDIAN WAR. And more interestingly, the phrase appears to be padded with 0x20 - the same character that separates the words. I leapt to the conclusion that the program just processes 0x20 as an unprintable and unguessable character, and that all the answers would have a lot of 0x20s in their plaintext.

Given that, and noticing that the answer board in the game is a consistent 13 characters wide by 4 characters tall, it wasn’t a stretch to assume that Answers.dat contains some number of fixed-width entries 52 bytes in length representing answers.

The cyphertext we see exhibits a lot of repeated characters - 0x6a, 0x45 are super visible in the excerpt above. Might they correspond to 0x20 XOR secret_key? There are a lot of utilities for automatically applying XOR values to byte fields; xortool was easily to hand, and quickly gave me enough results to realize that there were 4 XOR “key” values of one byte each, applied to all the characters in each “line”.

Let’s have another look at the cyphertext from our first example. The first several lines don’t have the repetitive structure we’d expect from stanzas of 4*13 characters, so let’s skip to something likely-looking:

000000c0: 0000 0000 0000 0000 0f6a 6a6a 6a6a 6a6a  .........jjjjjjj
000000d0: 6a6a 6a6a 6a41 252e 2f46 3541 332e 222a  jjjjjA%./F5A3."*
000000e0: 4141 4b4b 3f23 2e4b 2924 2a3f 4b4b 4b45  AAKK?#.K)$*?KKKE
000000f0: 4545 4545 4545 4545 4545 4545 0208 061f  EEEEEEEEEEEE....
00000100: 0f6a 0918 0519 196a 6a41 4120 2f25 4123  .j.....jjAA /%A#
00000110: 2d34 2441 4141 4b4b 4b38 2322 2e27 2f4b  -4$AAAKKK8#".'/K
00000120: 4b4b 4b45 4545 4545 4545 4545 4545 4545  KKKEEEEEEEEEEEEE
00000130: 0b6a 6a6a 6a6a 6a6a 6a6a 6a6a 6a41 412a  .jjjjjjjjjjjjAA*

Our first 13 characters get XOR’ed with 0x4a, to give 13 " " characters:

let bytewise_xor key s = String.map (fun c -> (int_of_char c) lxor key |> Char.chr) s;;
val bytewise_xor : int -> string -> string = <fun>
bytewise_xor 0x4a "jjjjjjjjjjjjA";;
- : string = "            \011"

We’d expect the game to render this as a “blank” first line, with no guessable characters. Surprisingly, the last character renders outside of the usable printable ASCII space – let’s put a pin in that.

Our second line gives us a lot of 0x20’s if we XOR it with 0x61, so let’s try our bytewise_xor with that:

bytewise_xor 0x61 "\x25\x2e\x2f\x46\x35\x41\x33\x2e\x22\x2a\x41\x41\x41\x4b";;
- : string = "DON'T ROCK   *"

Once again our last character is surprising; maybe we’re off by one?

bytewise_xor 0x61 "\x41\x25\x2e\x2f\x46\x35\x41\x33\x2e\x22\x2a\x41\x41\x41";;
- : string = " DON'T ROCK   "

That looks a bit more like what we expect, so what’s the result if we shove our first line analysis over by one character?

bytewise_xor 0x4a "\x0fjjjjjjjjjjjj";;
- : string = "E            "

Hm, still a bit weird; we’ll keep our pin in it, but let’s assume the chunking that gives us a clear " DON’T ROCK " is the correct one for the moment, and see about the third line:

bytewise_xor 0x6b "KK?#\x2eK)$*?KKK";;
- : string = "  THE BOAT   "

Definitely a Wheel-of-Fortune looking phrase! (And only good advice in the most literal sense.) Our last line is another blank one:

bytewise_xor 0x65 "EEEEEEEEEEEEE";;
- : string = "             "

Given this, we can try to figure out where the boundaries between answers are, and “decrypt” them. Angstrom is a good fit for a character-based parser like this:

let bytewise_xor key s = 
  String.map (fun c -> (int_of_char c) lxor key |> Char.chr) s

let s1_key = 0x4a
let s2_key = 0x61
let s3_key = 0x6b
let s4_key = 0x65

(* the answers file starts with 200 characters that aren't answers,
   so let's ignore them *)
let header_length = 200

module Parser = struct
  open Angstrom
  (* skip the first 200 bytes *)
  let header = take header_length

  (* a parser for our mystery byte at the beginning of line 1 *)
  let mystery_byte = any_uint8

  (* take the rest of the characters and "decrypt" them *)
  let l1 = take 12 >>| bytewise_xor s1_key
  let l2 = take 13 >>| bytewise_xor s2_key
  let l3 = take 13 >>| bytewise_xor s3_key
  let l4 = take 13 >>| bytewise_xor s4_key

  (* parse answers one by one *)
  let answer =
    mystery_byte >>= fun c ->
    l1 >>= fun l1 -> 
    l2 >>= fun l2 ->
    l3 >>= fun l3 ->
    l4 >>= fun l4 ->
    return (c, l1, l2, l3, l4)

  (* the file consists of the header, which we ignore, and then
     an arbitrary (to us, anyway!) number of answers *)
  let file = header >>= fun _ -> many answer

end

Invoking this parser on the ANSWERS.DAT distributed with Wheel of Fortune gives us the satisfying result of every answer in order:

$ dune exec -- decoder ~/Downloads/wheel-of-fortune/answers.bak|head -4
f: [            ] [ DON'T ROCK  ] [  THE BOAT   ] [             ]
2: [BLUE CROSS  ] [  AND BLUE   ] [   SHIELD    ] [             ]
b: [            ] [  KINGSTON   ] [   JAMAICA   ] [             ]
8: [            ] [ COPERNICUS  ] [             ] [             ]

That first thing is our mystery byte, so let’s see what values we get for it:

$ dune exec -- decoder ~/Downloads/wheel-of-fortune/answers.bak|sort|cut -f1 -d' '|uniq -c
    104 0:
     42 2:
    193 8:
    158 9:
    168 b:
     27 c:
    106 d:
     21 e:
    189 f:

There aren’t too many values used here, so maybe we can figure out what they mean by looking at what answers they’re associated with:

$  dune exec -- decoder ~/Downloads/wheel-of-fortune/answers.bak|sort|grep -E "^f:"|head -5
f: [ A DIAMOND  ] [   IN THE    ] [    ROUGH    ] [             ]
f: [            ] [   ALL OR    ] [   NOTHING   ] [             ]
f: [A NEW BROOM ] [   SWEEPS    ] [    CLEAN    ] [             ]
f: [            ] [   AT THE    ] [ CROSSROADS  ] [             ]
f: [            ] [ BEHIND THE  ] [ EIGHT BALL  ] [             ]

The stuff with f seems to be phrases? Wheel of Fortune provides a “category” for each clue, so maybe this byte is that. “Phrase” seems as good a category as any for “HIGH RESOLUTION TEXTURES”, so let’s try writing a printer and writing our clue into ANSWERS.DAT with that category.

Write an Answers with Our Puzzle Solution

Faraday, Angstrom’s close friend, is pretty easy to write a complementary printer in:

  let header b =
    for _ = 0 to (header_length - 1) do
      Faraday.write_uint8 b 0
    done


  let literally_answer b first_byte s1 s2 s3 s4 =
    let open Faraday in
    write_uint8 b first_byte;
    write_string b ~len:12 (bytewise_xor s1_key s1);
    write_string b ~len:13 (bytewise_xor s2_key s2);
    write_string b ~len:13 (bytewise_xor s3_key s3);
    write_string b ~len:13 (bytewise_xor s4_key s4);
    ()

  let spaces_string n = String.init n (fun _ -> ' ')

  let answer b first_byte l =
    let f = literally_answer b first_byte in
    match l with
    | a :: b :: c :: d :: _ -> f a b c d
    | a :: b :: c :: [] -> f a b c (spaces_string 13)
    | a :: b :: [] -> f (spaces_string 12) a b (spaces_string 13)
    | a :: [] -> f (spaces_string 12) a (spaces_string 13) (spaces_string 13)
    | [] -> f (spaces_string 12) (spaces_string 13) (spaces_string 13) (spaces_string 13)

Using this program to make a new ANSWERS.DAT seems like it should work, but we encounter a problem: the program crashes immediately on an attempted division by zero. Using the “header” from the shipped ANSWERS.DAT keeps this from happening, so our header definition ends up a bit more complicated:

  let header b =
    Faraday.write_uint8 b 0xf0;
    Faraday.write_uint8 b 0x01;
    for _ = 0 to 197 do
      Faraday.write_uint8 b 0
    done

If we write a new ANSWERS.DAT with just one answer in it, the program again crashes after trying to read off the end of the file. We can ensure that we always get our clue for the all-important screenshot by just writing the same clue over and over. The original ANSWERS.DAT has 1008 answers, so we can write our answer 1008 times.

Such a file starts off like this:

$ dune exec -- encoder Answers.dat 15 "    HIGH    " "  RESOLUTION " "   TEXTURES  "
$ xxd Answers.dat |head -100
00000000: f001 0000 0000 0000 0000 0000 0000 0000  ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000c0: 0000 0000 0000 0000 0f6a 6a6a 6a02 030d  .........jjjj...
000000d0: 026a 6a6a 6a41 4133 2432 2e2d 3435 282e  .jjjjAA3$2.-45(.
000000e0: 2f41 4b4b 4b3f 2e33 3f3e 392e 384b 4b45  /AKKK?.3?>9.8KKE
000000f0: 4545 4545 4545 4545 4545 4545 0f6a 6a6a  EEEEEEEEEEEE.jjj
00000100: 6a02 030d 026a 6a6a 6a41 4133 2432 2e2d  j....jjjjAA3$2.-

And if we copy this Answers.dat to where Wheel of Fortune expects to see it, we finally get our prize!

Get the Screenshot

Screenshots are Ctrl+F5 in Dosbox.

Cross-Stitchify the Screenshot

ih is a Python program for transforming raster images into cross-stitch patterns. stitchcraft is capable of doing further transformations and estimates with the generated cross-stitch pattern. We’ll use those tools together to get a vaguely-sensible pattern.

The raw screenshot makes a REAL big cross-stitch pattern! Cropping it and scaling it down by 50% yields a somewhat more reasonable estimate of effort. Here’s the Makefile for a maximally-reasonable, still-satisfactory result:

$ cat Makefile
SOURCE ?= wheel-bonus-solution.png

scaled.png : $(SOURCE)
	convert -sample 50% -crop 320x100+0+0 $< $@

wheel.layers : scaled.png
	ih -o json -p floss $< > $@

wheel.pattern : wheel.layers
	assemble -g 18 --bg=0,0,0 -i $< -o $@ --exclude=310

wheel.pdf : wheel.pattern
	stitchpattern -w "winners get a free trip to http://stitch.website" -i $< -o $@

wheel.png : wheel.pattern
	listing -i $< -o $@

stitchcraft’s automatic estimation tools give the following for even our smaller wheel.pattern:

 estimate wheel.pattern
aida cloth: 20.00 by 8.00 inches (including 1.00 margin) - approximate cost: USD 1.5
a scroll frame with small dimension of 7 inches
custom frame, of minimum size 8.0 x 20.0
DMC 703: Chartreuse: 2184 stitches (582.40 linear inches, 0.93 standard skeins, USD 0.93, ~21840 seconds)
DMC 307: Lemon: 11457 stitches (3055.20 linear inches, 4.88 standard skeins, USD 4.88, ~114570 seconds)
DMC 3801: Melon Very Dark: 340 stitches (90.67 linear inches, 0.14 standard skeins, USD 0.14, ~3400 seconds)
total cost: 6; total time: 139810 seconds (2330 minutes) (39 hours)

Decide You Don’t Need to Do That

Looking at this estimate and my lovely screenshot, I concluded that I had exorcised the “HIGH RESOLUTION TEXTURES” demon and didn’t need to continue. If you feel like concluding this effort, feel free to take up the torch and let me know how it goes - I bet punching that pillow is real satisfying.

The parser and printer I wrote for this silly endeavor is available on GitHub, for fellow nostalgics.