275 lines
10 KiB
Markdown
275 lines
10 KiB
Markdown
# 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
|
|
|
|
```java
|
|
// In com.gcemu.gcpp.packets.Opcode
|
|
|
|
PLAYER_JOIN_REQ(50, "PLAYER_JOIN_REQ",
|
|
"Player join request", Direction.CLIENT_TO_SERVER),
|
|
```
|
|
|
|
### 2. Create the Parser
|
|
|
|
```java
|
|
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
|
|
|
|
```java
|
|
// 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
|
|
|
|
```java
|
|
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 `CompressedPayloadParser` for packets that
|
|
> may have compressed content. For packets that are never compressed
|
|
> (key exchange, heartbeats), a plain `PacketParser` is fine.
|
|
|
|
## Direction Detection
|
|
|
|
```java
|
|
context.isClientToServer() // dstPort == serverPort
|
|
context.isServerToClient() // srcPort == serverPort
|
|
```
|
|
|
|
For server port 9501:
|
|
- `client:51094 -> server:9501` = **Client -> Server**
|
|
- `server: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
|
|
|
|
```bash
|
|
java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9501
|
|
```
|