Client Mapping
Client mapping is the process of mapping an obfuscated name in the injected-client to a known structure, key method, field, or packet so that it can be used by your API. Each client revision the obfuscation patterns change, so your mappings will instantly be out of date. So how do you even start finding what's what in a fully obfuscated client?
Prerequisites
You will need the following tools:
- An IDE like IntelliJ or Eclipse
- A decompiler like Fernflower or CFR (IDE's like IntelliJ have built-in decompilers)
- A client jar file (see below)
- A basic understanding of how the client works see mixins and packets docs for more info.
Getting the Client
It starts by actually getting the client jar file! You can download RuneLite's client jar from:
https://repo.runelite.net/net/runelite/injected-client/<version>/injected-client-<version>.jar
For example, to get v1.12.23 you would do:
curl -o injected-client-1.12.23.jar https://repo.runelite.net/net/runelite/injected-client/1.12.23/injected-client-1.12.23.jarVoilà, you now have the client jar, and you can add it into your IDE to start digging through the actual classes.
Note: Since the classes are obfuscated, they will all be named with 2 letters like: aa, fg, dc, etc. This is to be expected.
The doAction method
You may have heard the mention of the doAction method. This method is crucially important in the client for three key reasons:
- It handles menu action clicks for a ton of different clicks in the game, including widgets, NPC's, players, ground objects, game objects, and more. It's essentially a one-stop shop to figuring out how clicks are processed in the client and is a great place to start your mapping journey.
- There are hardcoded static opcode values in the
doActionobfuscated source which directly correspond to RuneLite's MenuAction enum. This means we can 1:1 map a known menu action click to a specific place in the client where it is processed. - RuneLite injects a callback into
doActionwith some static hardcoded strings. All we have to do is find the strings in the obfuscated source, and we instantly know that the method containing the string isdoAction!
The string we are looking for to identify the class and method is: Unable to find clicked menu op.
Note: There are a lot of ways to identify the
doActionmethod, this is just one way. Feel free to be creative and find some other ways!
Example doAction method
Let's take a look at an example obfuscated doAction method and break it down (its a big method so this is just a snippet):
@Named({"̪̳̺̳̥̳̥͚̪̥̳̺̳͖̺̪̺̺͖͍͚̥̪̳̺̳̥̳̥͚̪̥̳\\u0022\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\\u00223한\r̪̳̺̳̥̳̥͚̪̥̳̪̳̺̳̥̳̥͚̪̥̳”̺̳͖̺̪̺̺͖͍͚̥̪̳̺̳̥̳̥͚̪̥̳\\u0022한\r\\u0022̪̳̺̳̥̳̥͚̪̥̳̺̳͖̺̪̺̺͖͍͚̥̪̳̺̳̥̳̥͚̪̥̳\\u0022\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\\u00223한\r̪̳̺̳̥̳̥͚̪̥̳̪̳̺̳̥̳̥͚̪̥̳”̺̳͖̺̪̺̺͖͍͚̥̪̳̺̳̥̳̥͚̪̥̳\\u0022한\r\\u0022", ... })
public static final void ek(int var0, int var1, int var2, int var3, int var4, int var5, String var6, String var7, int var8, int var9, int var10) {
rl5 var11 = client.qf(client.nl, var0, var1, var2, var3, var4, var5, var6, var7);
boolean var12 = false;
if (vz.nb != null) {
var12 = vz.nb.yd() == var2 && vz.nb.eu() == var3 && vz.nb.hx() == var6 && vz.nb.fi() == var7 && vz.nb.mq() == var0 && vz.nb.dk() == var1 && vz.nb.qq() == var4 && vz.nb.ab() == var5;
}
if (var11 == null) {
if (var8 != -1 || var9 != -1) {
client.rn.warn("Unable to find clicked menu op {} targ {} action {} id {} p0 {} p1 {} world {}", new Object[]{var6, var7, var2, var3, var0, var1, var5});
}
}
// ...
}The first thing we notice is that there is a @Named annotation with a garbage value in it. This is something RuneLite adds and can be safely stripped away using a tool like ASM to remove these @Named annotations. I believe these annotations are present to prevent naive abstract syntax tree (AST) and UI rendering tools from understanding the code. It may also encode some key data in it, but I do not know for sure and we don't need it anyway.
Remember: We aren't trying to get this code to run, we are just trying to understand what it does. Removing added complexity is the goal, not perfectly running code!
Notice the large number of args. When obfuscating methods, the arguments and their order do NOT change. This means we can trace key data that we know about the args through the method. For example, doActions args are (in order): int param0, int param1, int opcode, int identifier, int itemId, int worldView, String option, String target, int mouseX, int mouseY, int garbageValue You will notice these args closely align with the RuneLite's MenuOptionClicked event which is sent via a callback (see mixins) when this method is invoked!
Finally, we notice that there is a client.rn.warn call with our key string in it. We know with certainty that this is the doAction method. But what other key information can we derive from this?
MenuActions & DoAction
Like I mentioned earlier
There are hardcoded static opcode values in the
doActionobfuscated source which directly correspond to RuneLite's MenuAction enum. This means we can 1:1 map a known menu action click to a specific place in the client where it is processed.
If we take a look at the MenuAction enum specifically looking at the PLAYER_FIRST_OPTION (when a menu action is clicked for a player like "Follow") value we see that its ordinal is 44. If we scroll through the obfuscated source we eventually come across this block:
if (var17 == 44) {
if (var25 != -1948098697) {
throw new IllegalStateException();
}
cc var51 = (cc)var30.aq((long)var18);
if (var51 != null) {
if (var25 != -1948098697) {
throw new IllegalStateException();
}
client.ma = -1455505917 * var23;
client.mr = 215935915 * var24;
client.mu = 53463218;
client.mt = 0;
client.op = var15 * 535929121;
client.ok = var16 * -1588445337;
jg var82 = hk.ah(iq.dm, client.ay.az, (byte)89);
wg var104 = var82.ai;
byte var120;
if (client.ew.ad(82, -2123816514)) {
if (var25 != -1948098697) {
throw new IllegalStateException();
}
var120 = 1;
} else {
var120 = 0;
}
var104.dw(var120, (byte)-16);
var82.ai.eu(var18, 499124101);
client.ay.ae(var82, (byte)-62);
}
}You can see here that the doAction method is handling the PLAYER_FIRST_OPTION value in this block and this is actually where the packet is constructed. We know this because we see the constant value 44 being compared to var17. This is the 1:1 mapping between MenuAction constants and blocks which handle the menu actions in the client!
MenuAction.PLAYER_FIRST_OPTION(44) -> (var17 == 44) { ... }Pay particularly close attention to this line and read the network packets guide first or you'll be lost on how packets are created:
jg var82 = hk.ah(iq.dm, client.ay.az, (byte)89);This line is constructing the PacketBufferNode class (jg) which is the container for both the packet payload, type, and size. It takes three arguments:
- The
ClientPacketclass which contains static references to the types of packets being constructed.iq.dmwhereiqis theClientPacketclass anddmis the packet's obfuscated name. - The
IsaacCipherinstance which is used to encrypt the packet payload (client.ay.az).ayis actually the PacketWriter field as well - A garbage parameter that is meant to make calling this method with reflection harder.
Finally, we notice that hk is a class which contains a static method called ah which is used to call (or construct) getPacketBufferNode making the buffer node object.
Just from that one line of code we now know the:
- Static constructor for the
PacketBufferNodeclass (hk). - The
PacketWriterfield in theClientclass (ay). - The
IsaacCipherinstance used to encrypt the packet payload (az). - The
ClientPacketenum value used to construct the packet (iq). - That
dmis the obfuscated name of theOPPLAYER1packet
Digging Deeper
Let's press forward in the example above to see if we can glean some other information. var82 is now the packetBufferNode object and its immediately used in the next line: wg var104 = var82.ai; This contains two additional pieces of information:
wgis the name of theBufferclassaiis the name of packet buffer field.
This line constructs the Buffer class into which the payload of the packet will be written. Progressing further, we see the following two lines:
var104.dw(var120, (byte)-16);
var82.ai.eu(var18, 499124101);On our Buffer object (var104) we call the dw method passing in var120 and a garbage value -16. These two lines both write packet data into the buffer! Since we know this is the OPPLAYER1 packet, we know there are only 2 params in this packet ctrlDown and playerIndex but which is which?
The answer lies in this snippet of code just above:
if (client.ew.ad(82, -2123816514)) {
if (var25 != -1948098697) {
throw new IllegalStateException();
}
var120 = 1;
} else {
var120 = 0;
}Var120 is being passed into var104.dw(var120). We know that ctrlDown is always a boolean value being either:
- 1 (control was pressed)
- 0 (control was not pressed)
This helps us know that the order of the packet writes will be ctrlDown then playerIndex!
The order of packet writes is important as the game client scrambles the order every revision. For example, next time the order could be playerIndex written first and then ctrlDown is written. If you write the packet data out of order, the packet will be discarded by the server and will either cause a disconnect or do nothing (neither outcome is desirable). This order was easy to map because there are only two values being written to the buffer, but it gets trickier when there are 5+ values like the OPHELDD packet.
For a more in-depth explanation of the order of packet writes, see this section on packet write ordering.
Deobfuscating Packet Writes
Now that we have a solid understanding of where the packet writes are being done, we can essentially rewrite the obfuscated code like so:
var82.ai.eu(var18, 499124101);
// deobfuscated becomes ->
PacketBufferNode var82;
var82.packetBuffer.writeShort(var18, 499124101); // I think it's short but not 100% sureA quick note, sometimes the client will wrap the write in a static method call to the extended packet buffer class. For example,
// wj = extended Packet buffer class i.e. wg extends wj implements PacketBuffer
// ai = Packet buffer field
wj.of(var56.ai, var28 + param1, 1106217644);This is still nothing more than a different way of writing the packet data into the buffer.
Packet Writes
Still with me? Good, let's go a level deeper by exploring the actual packet write methods. We know that wg is the packet buffer class so let's start there since the writes have to go into the buffer. The first thing we see when exploring wg is that it extends wj and implements none other that PacketBuffer from RuneLite's API!
public class wg extends wj implements PacketBuffer
We don't see the dw method in wg but we do see it in the wj class.
public void dw(int var1, byte var2) {
try {
this.am[(this.as += 1044177923) * -288102741 - 1] = (byte)(var1 + 128);
} catch (RuntimeException var3) {
throw mr.ah(var3, "wj.dw(" + ')');
}
}By looking at this method we can see that am is the array of bytes that the packet buffer is writing to. as is the current offset in the array. We can track the multipliers used to calculate the offset in the array, and we see that a value of 128 is added to the packet write value.
This is another obfuscation technique where mathematical operations are applied to the data being written to the buffer to try to hide the actual value. For example,
// Normal way of just writing data to the buffer
PacketBufferNode var82;
var82.packetBuffer.writeShort(var18, 12834059); // We just write the value as is to the buffer
// What the client actually does (simplified)
var82.packetBuffer.writeAnnoyingShort(var18 >> 8, 12834059);If we were to observe the packet payload at runtime and print out the value of the byte array (even without the IsaacCipher) we would see that the value is actually right shifted, added, subtracted, or just the normal raw value. So to read back the values written into the packets buffer at runtime, we have to reverse these mathematical operations i.e., if we added 128 to write the value into the buffer, we have to subtract 128 from the value to read it.
If we looked at the popular EthanVann ObfuscatedNames.java file for OPPLAYER1 we would see this:
public static final String OPPLAYER1_OBFUSCATEDNAME = "dm";
public static final String OPPLAYER1_WRITE1 = "ctrlDown";
public static final String OPPLAYER1_METHOD_NAME1 = "cp";
public static final String OPPLAYER1_WRITE2 = "playerIndex";
public static final String OPPLAYER1_METHOD_NAME2 = "eu";
public static final String[][] OPPLAYER1_WRITES = new String[][]{
{"a 128"},
{"v", "r 8"},
};The a 128 is essentially referring to this method where 128 has to be added to the value being written. We've now correctly mapped:
- The PacketBuffer class (
wg) - The offset multiplier (
1044177923) - The index multiplier (
-288102741) - The packet write method (
dw) - The packet write method (
dw) - The packet write offset (
as) - The packet write array (
am) - The buffer operation applied to the value being written into the buffer (
adding 128)
If you compare this with Ethan's obfuscated names file you will see that we've actually mapped a large portion of the necessary information to be able to send packets reflectively with a single method!
Write Order
The order of packet writes is very important as the game client scrambles the order every revision. For example, if the server expects the EVENT_MOUSE_CLICK packet to be sent with the param order like: [mouseX, mouseInfo, 0, mouseY] but you are writing to the buffer in the order: [mouseInfo, 0, mouseX, mouseY] the packet will be discarded by the server and either cause a disconnect or do nothing (neither outcome is desirable).
If we look at this specific section of obfuscated code from the client class we will see the mouse click packet being constructed:
// Omitted some checks on local4 and local5 for brevity
int local4 = 1299232211 * sj.af;
int local5 = sj.ad * -1927899105;
int local6 = (int) local2;
// We know this constructs the packet
jg local7 = hk.ah(iq.aw, client.ay.az, -2);
// Which write is this? mouseX or mouseY?
local7.ai.eu(local4, -1740820771);
// This is the "0" param write
client.llimc = 0;
local7.ai.dn((byte) client.llimc, 1873899427);
int i45 = 0;
if (2 == sj.ai * 826155399) {
if (arg0 >= 1717905675) {
throw new IllegalStateException();
}
i45 = 1;
}
// This is the mouseInfo param write. We can discern it by the +i45 which is a 1 or 0 for left/right mouse button clicked.
local7.ai.eu((local6 << 1) + i45, -1544384910);
// Is this mouseX or mouseY?
local7.ai.bm(local5, -67);
// Send the packet with addNode()
client.ay.ae(local7, -123);The tricky part of this packet is we have no idea which write operation is mouseX and which is mouseY. To figure this out, we need to actually trace these values back through the client to see how they are being used.
The first write looks like: local7.ai.eu(local4, -1740820771); passing in the local4 variable. We see local4 is being set with int local4 = 1299232211 * sj.af; Let's dive into the sj class.
The first thing we notice about sj is that it has the signature:
public class sj implements MouseListener, MouseMotionListener, FocusListener {
Ah-ha! sj is the MouseHandler class! This is a huge clue and means we are on the right track. Within sj we see a method called mousePressed() and in this method we see the following:
at = var4.getX() * 1846599933;
ak = var4.getY() * 2095171719;We now know that sj.at = mouseX and sj.ak = mouseY but how does this relate to sj.af and sj.ad being passed into the packet write methods? Let's look at the client class again and see if we can find where sj.af and sj.ad are being set by searching for sj.af =
When we follow the trail in the client class we find a block of obfuscated code that looks like this:
sj.az = sj.ax * 1957132997;
sj.aj = 1474007645 * sj.aw;
sj.ap = sj.au * -815563039;
sj.aa = -5074286966289831135L * sj.al;
sj.ai = sj.ay * 127770809;
sj.ad = sj.at * -1631851253;
sj.af = sj.ak * 368527501;
sj.ab = sj.ar * 4991162732023366389L;Here we can see our sj.af value being set to sj.ak and the sj.ad value being set to sj.at. Since we know that sj.at = mouseX and sj.ak = mouseY we can now deduce that sj.af = mouseY and sj.ad = mouseX.
Taking this information back to our packet sending code where int local4 = 1299232211 * sj.af; and the first buffer write using local4 is local7.ai.eu(local4, -1740820771); we can deduce that mouseY is the first param being written while mouseX is the fourth param.
Using this strategy of tracing values back through the client, we can inherently understand the order of packet writes.
Conclusion
Client mapping might initially feel like trying to read an alien language, but as we've seen, it's really just an exercise in following the breadcrumbs. By leveraging the things we do know—like hardcoded log strings, MenuAction ordinals, and the basic logic of how a game must handle inputs—we can systematically tear down the obfuscation. What started as a massive, unreadable doAction method quickly unraveled into a clear map of packet construction, encryption, and buffer writes.
Remember, the obfuscated class names (wg), method names (dw), and the mathematical scrambling (like adding 128) will change with every single client revision. However, the patterns and the underlying structure of the client remain remarkably consistent.
The goal is never to memorize the garbage names, but to understand the flow of data. Armed with a decompiler, a bit of patience, and an understanding of these core client mechanics, you now have the foundational skills to start mapping out any interaction in the game. Happy hunting!