gcpp/docs/ADDING_PACKET_PARSERS.md

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 CompressedPayloadParser for packets that may have compressed content. For packets that are never compressed (key exchange, heartbeats), a plain PacketParser is fine.

Direction Detection

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

java -jar target/gcnet-decryptor-1.0.0-jar-with-dependencies.jar capture.pcapng 9501