10 KiB
10 KiB
Adding New Packet Parsers
This guide explains how to implement parsers for new GCNet packet types.
Architecture Overview
┌──────────────────────────────────────────────────────────────┐
│ PacketProcessor │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ PacketParserFactory │ │
│ │ ┌─────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ KeyExchangeParser│ │ VerifyAccountReqParser │ │ │
│ │ └─────────────────┘ └────────────────────────────┘ │ │
│ │ Strategy Pattern │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ PacketContext │
│ │
│ rawPayload() → full decrypted bytes │
│ content() → header stripped, decompressed if flagged │
│ isCompressed() → whether original had compression flag │
│ │
└──────────────────────────────────────────────────────────────┘
Design Patterns
| Pattern | Class | Purpose |
|---|---|---|
| Strategy | PacketParser |
Each opcode has its own parsing algorithm |
| Factory | PacketParserFactory |
Creates the correct parser per opcode |
| Context | PacketContext |
Immutable object with processed content |
| Registry | Opcode enum |
Maps numbers to names + direction hints |
| Null Object | GenericPayloadParser |
Fallback for unimplemented opcodes |
| Marker | CompressedPayloadParser |
Declares a parser needs decompressed content |
Step-by-Step: Adding a New Parser
1. Define the Opcode
// In com.gcemu.gcpp.packets.Opcode
PLAYER_JOIN_REQ(50, "PLAYER_JOIN_REQ",
"Player join request", Direction.CLIENT_TO_SERVER),
2. Create the Parser
package com.gcemu.gcpp.packets.parsers;
import com.gcemu.gcpp.packets.*;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Parser for PLAYER_JOIN_REQ (opcode 50).
*
* <h3>Content Structure (offset 0, header already stripped):</h3>
* <table border="1">
* <tr><th>Offset</th><th>Size</th><th>Type</th><th>Description</th></tr>
* <tr><td>0</td><td>4</td><td>int (LE)</td><td>Username byte length</td></tr>
* <tr><td>4</td><td>var</td><td>string (UTF-16LE)</td><td>Username</td></tr>
* <tr><td>var</td><td>4</td><td>int (LE)</td><td>Character class</td></tr>
* </table>
*/
@PacketParser.PacketParserFor(opcode = 50)
public class PlayerJoinReqParser implements PacketParser {
@Override
public ParseResult parse(PacketContext context) {
// content() = raw payload minus the 7-byte GCNet header.
// If this parser implemented CompressedPayloadParser, the
// content would also be zlib-decompressed automatically.
byte[] content = context.content();
if (content.length < 4) {
return ParseResult.empty(Opcode.PLAYER_JOIN_REQ, "Client -> Server");
}
int offset = 0;
Map<String, String> fields = new LinkedHashMap<>();
// Parse username length
int nameLen = ByteBuffer.wrap(content, offset, 4)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt();
offset += 4;
// Parse username
if (offset + nameLen <= content.length) {
String name = new String(content, offset, nameLen, StandardCharsets.UTF_16LE);
fields.put("Username", name);
offset += nameLen;
}
// Parse character class
if (offset + 4 <= content.length) {
int charClass = ByteBuffer.wrap(content, offset, 4)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt();
fields.put("Character Class", String.valueOf(charClass));
}
return ParseResult.parsed(
Opcode.PLAYER_JOIN_REQ,
"Client -> Server",
"PLAYER_JOIN_REQ { username: string_utf16, class: int32 }",
fields,
formatHex(content),
"player=\"" + fields.getOrDefault("Username", "?") + "\""
);
}
private String formatHex(byte[] data) {
var sb = new StringBuilder();
for (int i = 0; i < data.length; i++) {
if (i % 16 == 0 && i > 0) sb.append("\n");
sb.append(String.format("%02X ", data[i]));
}
return sb.toString().trim();
}
}
Key rules:
context.content()returns only content — no header, no padding- Start parsing at offset 0
- If the packet was compressed and your parser implements
CompressedPayloadParser, it's already decompressed
3. Register the Parser
// In PacketParserFactory.registerBuiltInParsers()
registerParser(50, new PlayerJoinReqParser());
Handling Compressed Packets
Some GCNet packets have zlib-compressed content. The framework handles decompression automatically — you just need to declare intent.
Add CompressedPayloadParser to Your Parser
package com.gcemu.gcpp.packets.parsers;
import com.gcemu.gcpp.packets.*;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Parser for a packet with compressed content (e.g., opcode 3).
*
* <p>By implementing {@link CompressedPayloadParser}, the framework
* decompresses the content before {@code parse()} is called.</p>
*/
@PacketParser.PacketParserFor(opcode = 3)
public class ServerContentsParser implements PacketParser, CompressedPayloadParser {
@Override
public ParseResult parse(PacketContext context) {
// context.content() is already:
// 1. Stripped of the 7-byte GCNet header
// 2. Decompressed via zlib (because compression flag was set)
byte[] content = context.content();
// True if the original packet had compression flag = 1
boolean wasCompressed = context.isCompressed();
Map<String, String> fields = new LinkedHashMap<>();
fields.put("Was Compressed", String.valueOf(wasCompressed));
fields.put("Decompressed Size", String.valueOf(content.length));
// Parse the decompressed content as needed...
return ParseResult.parsed(
Opcode.valueOf(3),
resolveDirection(context),
"SERVER_CONTENTS { data: bytes (zlib decompressed) }",
fields,
formatHex(content),
""
);
}
private String resolveDirection(PacketContext ctx) {
return ctx.isClientToServer() ? "Client -> Server" : "Server -> Client";
}
private String formatHex(byte[] data) {
var sb = new StringBuilder();
for (int i = 0; i < data.length; i++) {
if (i % 16 == 0 && i > 0) sb.append("\n");
sb.append(String.format("%02X ", data[i]));
}
return sb.toString().trim();
}
}
Content Flow
Wire (encrypted):
[DES-CBC encrypted: header(7) + [decompressedSize(4) + zlibData...]]
│
PacketProcessor.decryptPacket() ▼
[opcode:2][size:4][flag:1][decompressedSize:4][zlibData...]
│
▼
PacketParserFactory detects CompressedPayloadParser
(or just strips header if not compressed)
│
▼
PacketContext.content()
[Decompressed content bytes — offset 0]
│
▼
Your parser — parse from offset 0
CompressedPayloadParser vs Plain PacketParser
Plain PacketParser |
+ CompressedPayloadParser |
|
|---|---|---|
| Header stripping | ❌ No — raw payload | ✅ Stripped |
| Decompression | ❌ No | ✅ Automatic |
context.content() |
Returns full raw payload | Returns processed content |
context.isCompressed() |
Available | Available |
| Best for | Key exchange, small control packets | Large data packets with compressed content |
Tip: Always implement
CompressedPayloadParserfor packets that may have compressed content. For packets that are never compressed (key exchange, heartbeats), a plainPacketParseris fine.
Direction Detection
context.isClientToServer() // dstPort == serverPort
context.isServerToClient() // srcPort == serverPort
For server port 9501:
client:51094 -> server:9501= Client -> Serverserver:9501 -> client:51094= Server -> Client
File Structure
src/main/java/com/gcemu/gcpp/packets/
├── Opcode.java ← Opcode registry
├── PacketContext.java ← Processed content + direction
├── PacketParser.java ← Strategy interface + annotation
├── CompressedPayloadParser.java ← Marker for compressed content
├── PayloadContentExtractor.java ← Header stripping + zlib decompression
├── PacketParserFactory.java ← Factory + registration
└── parsers/
├── GenericPayloadParser.java ← Fallback
├── HeartBeatParser.java ← Opcode 0
├── KeyExchangeParser.java ← Opcode 1 (uses content())
└── VerifyAccountReqParser.java← Opcode 2 (uses content())
Testing
java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9501