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 doing something with them.
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.exe
2 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 if
3 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.
-
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. ↩
-
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. ↩
-
This may have been a
switch
statement in the original and the decompiler can't tell the difference. ↩ -
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.