A Weird Imagination

Extracting Tametsi puzzles

The problem#

Tametsi (available on Steam1) is a great logic puzzle game that is a collection of Minesweeper puzzles that can be solved without guessing. The game consists of 100 puzzles plus 60 "bonus" puzzles. The bonus puzzles are in the game directory in an XML-based format that another player has documented well enough that they have even created some puzzles of their own and a viewer for those files. But the base 100 puzzles are nowhere to be found in the puzzle directory, and I had ideas for

The solution#

Given the file DumpPuzzles.java:

public class DumpPuzzles {
    public static void main(String[] args) {
        // Loading puzzles sets the graph on
        //  Game's MouseHandler, so it has to exist.
        game.Game.mh = new io.MouseHandler(null);

        for (int i = 1; i <= 111; i++) {
            puzzle.PuzzleOut.writePuzzle(
                new puzzle.Puzzle(i),
                String.format("puzzle_%03d.puz", i));
        }
    }
}

put it in the same directory as tametsi.exe2 and run

$ javac -classpath tametsi.exe DumpPuzzles.java 
$ java -classpath tametsi.exe:. DumpPuzzles

Then the puzzles/ directory will be full of files named puzzle_001.puz, etc.

The details#

Decompiling the game#

Since the hint on using Java to run the game on Linux said to point java -jar at tametsi.exe, I knew it was actually a valid JAR file. Furthermore, JAR files are just ZIP files with a particular structure, so the next step was to unzip it and look inside. At first I thought I might file .puz files inside the archive but that was not the case.

To actually view the code, I used the Procyon Java decompiler. As packaged for Debian, the usage is just procyon SomeClass.class. It outputs the decompiled Java code in a nice colorized output, although unfortunately with the large files I was looking at, it was slow enough that I just dumped the output to a file: procyon SomeClass.class > SomeClass.java.

Looking around for clases name something like "level", "board", or "puzzle", I found the Puzzle and PuzzlePresets classes in the puzzle namespace. The top of PuzzlePresets is a big if/else if3 calling methods named loadPuzzle1() through loadPuzzle110()4. Looking further down in that same file, each of those programmatically defines a puzzle.

Setting up Eclipse#

At this point, I was ready to actually write some code, so I wanted a Java IDE so I could have access to a debugger. I installed Eclipse, since it was the main Java IDE last time I did much Java coding, although Wikipedia tells me, it was apparently unseated by IntelliJ IDEA several years ago now.

The first thing I noticed when I tried to use Eclipse was a weird flickering whenever I typed something. I was able to find this suggestion to set GTK_IM_MODULE=ibus which fixed it for me.

Next, I wanted a decompiler plug-in, so when debugging into Tametsi's code I could understand what was going on. I installed the plugin Enhanced Class Decompiler, which supports multiple decompiler backends including Procyon. One detail to getting it to work is that I had to follow the instructions on the plugin's website to configure the "file association" settings in Eclipse in order to get it to actually decompile during a debugging session.

Then I just created a new project with Tametsi.exe as a JAR dependency and started writing code referencing it.

Writing some exploratory code#

There's a Puzzle constructor that takes an int, so the first thing I tried was to just do new Puzzle(1) and see what I got. Unfortunately, what I got was a NullPointerException, so I had to try something different. The PuzzlePresets.loadPuzzle(int, Puzzle) function looked promising, although I still needed to somehow get a Puzzle object to feed it. As new Puzzle() without an argument worked and didn't throw an exception, that was possible. Loading the puzzle still threw an exception, but I could catch it and ignore it and see that the Puzzle object had information filled in:

Puzzle puzzle = new Puzzle();
try {
    PuzzlePresets.loadPuzzle(1, puzzle);
} catch (NullPointerException e) { /* Ignore. */ }
// Use puzzle…

Once I had a Puzzle object, I could inspect it the debugger to get an idea of what it looked like and what fields I would have to save.

Exporting as JSON#

Since the point was to get the Puzzle into a format that I could use in other code, I looked into Java libraries for serializing objects as JSON and found Gson.

I ran new Gson().toJson(puzzle) and promptly got a StackOverflowError. This is a common problem with automatic serialization of types not intended for it, so they have circular references. The workaround is to find where the circular references are and tell the serializer to not follow them.

So I looked at the Puzzle object in the debugger and found that it contains a Graph object that contains nodes pointing back at the Graph. So I followed those instructions to special case Graph so Gson wouldn't try to serialize it and instead explicitly serialize its fields:

static class NullTypeAdapter<T> extends TypeAdapter<T> {
    public T read(JsonReader reader) throws IOException {
        return null;
    }

    public void write(JsonWriter writer, T obj)
            throws IOException {
        if (obj == null) {
            writer.nullValue();
            return;
        }
        writer.value(obj.toString());
    }
}
// ...
gson = new GsonBuilder()
    .registerTypeAdapter(Graph.class,
                         new NullTypeAdapter<Graph>())
    .create();
String json = "{"
+ "{"
+ "\"puzzle\":" + gson.toJson(puzzle) + ","
+ "\"fontFactor\":" + gson.toJson(puzzle.G.fontFactor) + ","
+ "\"numFlags\":" + gson.toJson(puzzle.G.numFlags) + ","
+ "\"numMines\":" + gson.toJson(puzzle.G.numMines) + ","
+ "\"V\":" + gson.toJson(puzzle.G.V)
+ "}";

Importing the bonus puzzles#

Now that I had a JSON export of the main puzzles, I thought it would be convenient to get the bonus puzzles into the same format. I figured there must be some code for loading the XML format of the bonus puzzles into the Puzzle type, and, indeed, I found puzzle.PuzzleIn which has a strightforward API:

Puzzle puzzle = PuzzleIn.readXMLPuzzle(filename);

Then I was able to use the same code to output those puzzles as JSON.

Where there's a PuzzleIn there's a PuzzleOut#

As I should have expected, right next to the PuzzleIn class for reading the .puz bonus puzzle format into a Puzzle object, there is also a PuzzleOut class for writing Puzzle objects into those .puz files. Especially once I realized there was already code for processing .puz files, it made a lot more sense to just normalize everything to that format instead of inventing my own new JSON format that I would then need to write an interpreter for:

PuzzleOut.writePuzzle(puzzle, "puzzle_001.puz");

Since the serialization code was already there, I both could be pretty sure it is doing the right thing and it doesn't require the additional dependency on Gson.

Fixing the exception#

I still had the NullPointerException on loading every puzzle that I was ignoring. But looking at the decompiled puzzle loading code and using the debugger, I noticed that there was in fact further initialization of the Puzzle object happening after it, at least some of the time.

Using the debugger, I was able to see the actual line causing the exception was

Game.mh.setGraph(P.G);

where Game.mh is an io.MouseHandler, which sounded like something that might be difficult to construct without also setting up some GUI context. But then I actually looked at Game and MouseHandler in the debugger and saw the constructor and setGraph() methods were just setting fields and not attempting any further computation, so I could actually make a dummy MouseHandler that would avoid the exception pretty easily, since the Game passed into MouseHandler never actually gets read along the codepaths involving just loading puzzles and not playing them:

// Loading puzzles sets the graph on
//  Game's MouseHandler, so it has to exist.
game.Game.mh = new io.MouseHandler(null);

Putting it all together#

The only change to get from the in-progress prototype to the final version was to actually wrap the code in a loop over all of the puzzles. One notable detail is that the final code is significantly shorter than the intermediate prototypes. Part of that is just removing debugging statements to print out information. But also figuring things out allowed me to greatly simplify the code by, for example, realizing the entire JSON serialization idea was unnecessary and could be replaced by a single line call to the existing XML serialization. A good reminder that improving code often means removing, not adding, lines of code.


  1. The Steam page says Windows-only, but in addition to working okay under Proton, since the game is pure Java, it can be run using native Java on Linux

  2. In your Steam library, right-click on the game and select "Manage" and then "Browse local files" if you're not sure where it is. 

  3. This may have been a switch statement in the original and the decompiler can't tell the difference. 

  4. Yes, that's all 110 of the 100 puzzles. The extra 10 puzzles appear a combination of tests, leftovers that didn't make it into the game, and the "Congratulations!" screen for completing all puzzles without mistakes. 

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.