Skip to content

Writing Mappings

Julian Burner edited this page Nov 18, 2023 · 8 revisions

Writing mappings

Prerequisites

For the sake of this tutorial, let's assume you have made the following renames in your project:

class_1              → GoldBlock
├── field_1          → hardness
├── method_1         → getBlockType
├── method_2         → addTag
│   ├── argument_1   → tag
│   ├── argument_2   → overwriteExistingTags
│   ├── variable_1   → counter

Providing the data

In order to export your mappings to a file, you first have to create a new MemoryMappingTree instance:

MemoryMappingTree tree = new MemoryMappingTree();

All our following operations will be done using this tree.

Header data

Before we can tell our tree instance which renames we've made, we have to provide some basic metadata. This is usually what ends up in the mapping files' first row, like here for the Tiny format:

v1	official	intermediary

Some columns, like the v1 here, are part of the individual mapping formats' specifications and are being taken care of by their respective Writers automatically. Other parts, like the official and intermediary, have to be specified by us library consumers manually. They represent the names of our source and target mappings. Generally it's recommended to go with source for the original source names and target for your new names, but it doesn't really matter. Passing this information to our tree can be done the following way:

tree.visitHeader();
tree.visitNamespaces("source", Arrays.asList("target"));

Mapping data

Right now, our tree's content is empty, so let's start by adding our first mapping. We always have to start with the classes, as they are highest up in the hierarchy. Note that, since we've previously written header data, we first have to tell the tree that we're now adding actual mapping content:

tree.visitContent();
tree.visitClass("somepackage/class_1");

This adds our class to the tree's internal state, from where we can perform further actions, like providing a new name and/or adding a comment etc. Since we do in fact have a new name to apply, we can tell the tree to assign that to the current (last visited) class:

tree.visitDstName(MappedElementKind.CLASS, 0, "GoldBlock");

0 is the index of the namespace we wish to add this name to (see Header Data), in our case this is the "target" namespace/column.

Fields and methods

From here on, it's basically the same concept: Visit the corresponding parent class first (which we've already done above), then all of its members you wish to add renames for:

// Fields
tree.visitField("field_1", fieldDescriptorOf("field_1"));
tree.visitDstName(MappedElementKind.FIELD, 0, "hardness");

// Methods
tree.visitMethod("method_1", methodDescriptorOf("method_1"));
tree.visitDstName(MappedElementKind.METHOD, 0, "getBlockType");
tree.visitMethod("method_1", methodDescriptorOf("method_2"));
tree.visitDstName(MappedElementKind.METHOD, 0, "addTag");

You may be confused by the fieldDescriptorOf(...) and methodDescriptorOf(...) methods. These are not part of mapping-io, you need to implement them yourself. See these presentation slides if you don't know yet what descriptors are. Basically, they are needed to uniquely identify a member - otherwise you wouldn't be able to distinguish between e.g. void myMethod(String string) and boolean myMethod(int num), as both methods have the same name.

Method arguments and variables

Here's where things get tricky: Up until now, using the original source names (and sometimes a descriptor) were enough to identify a mappable element. Method args and vars on the other hand aren't guaranteed to even have names, so we can't rely on that factor anymore. As a solution, a combination of:

  • the method arg position
  • the local variable index
  • and/or the opcode of the variable

is used.

Method args
// Make sure you've visited the method you
// intend to add these args to beforehand!

tree.visitMethodArg(argPosition, lvIndex, srcName);
// - argPosition always starts at 0 and gets incremented
//   by 1 for each additional arg.
// - lvIndex starts at 0 for static methods, 1 otherwise.
//   For each additional arg, it gets incremented by 1,
//   or by 2 if it's a primitive long or double.
// - srcName is optional (at least for the Tiny v2 format).

tree.visitMethodArg(0, 0, "argument_1");
tree.visitDstName(MappedElementKind.METHOD_ARG, 0, "tag");
tree.visitMethodArg(1, 1, "argument_2");
tree.visitDstName(MappedElementKind.METHOD_ARG, 0, "overwriteExistingTags");
Method vars
// Make sure you've visited the method you
// intend to add these vars to beforehand!

tree.visitMethodVar(lvtRowIndex, lvIndex, startOpIdx, srcName)
// - lvtRowIndex is the variable's index in the method's LVT
//   (local variable table). It is optional, so you can pass -1 instead.
//   This is the case since LVTs themselves are optional debug information, see
//   https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.13
// - lvIndex is the local variable's index in the current method.
//   For each additional variable, it gets incremented by 1,
//   or by 2 if it's a primitive long or double.
// - startOpIndex is required for cases when lvIndex alone doesn't
//   uniquely identify a local variable. This is the case when vars
//   get re-defined later on, in which case most decompilers opt to
//   not re-define the existing var, but instead generate a new one.
// - srcName is once again optional, as Java bytecode doesn't
//   require local vars to have an associated name.

tree.visitMethodVar(lvtRowIndex, lvIndex, startOpIdx, "variable_1");
tree.visitDstName(MappedElementKind.METHOD_VAR, 0, "counter");

Comments

All tokens can be assigned comments to via visitComment(...):

tree.visitField("field_1", fieldDescriptorOf("field_1"));
tree.visitComment(MappedElementKind.FIELD, "I'm a comment");

Exporting to a file

After all your renames have been passed to the tree object, we can finally write everything to a file. For this, create a new MappingWriter instance:

tree.visitEnd()
MappingWriter writer = MappingWriter.create(outputFilePath, MappingFormat.TINY_2);

You can choose whatever mapping format you like, but note that not all formats support all features. Generally it's best to go with Tiny v2, as it has the largest feature set.

Let's pass this writer to the tree's accept method:

tree.accept(writer);

This automatically writes all of our mappings to the above specified output path.

The only thing that's left now is to close the writer:

writer.close();

Congratulations! You've successfully exported your first set of mappings.