Wenn sich eine Figur bewegt, ein Gegenstand aufgehoben oder eine Chat-Nachricht gesendet wird, ist alles, was zwischen Client und Server fließt, ein metin2 packet. Das Netzwerkprotokoll von Metin2 besteht aus binären Nachrichten, die über TCP verschickt werden, wobei das allererste Byte den Typ des Pakets bestimmt. In diesem Artikel zeige ich Schritt für Schritt, wie Pakete aufgebaut sind, was die Header-Benennung bedeutet, worin sich feste und dynamische Pakete unterscheiden und wie der Server eingehende Bytes dekodiert und an den richtigen Handler weiterleitet.
Wie ein Paket aussieht: das Header-Byte
Im Metin2-Protokoll beginnt jedes Paket mit einem einzigen Header-Byte. Da es sich um ein BYTE handelt, nimmt es einen Wert von 0 bis 255 an und identifiziert den Pakettyp. Sobald Server oder Client das erste Byte aus dem Stream liest, weiß er, um welches Paket es sich handelt und damit, wie viele Bytes er noch lesen muss.
Pakete werden in C++ als struct definiert und mit #pragma pack(1) gepackt, damit es im Speicher kein Byte-Padding gibt. Das ist entscheidend: Wendet der Compiler seine Standard-Ausrichtung an, sehen Client und Server unterschiedliche Byte-Layouts und das Protokoll bricht.
#pragma pack(1)
// Client -> Game: einfaches Bewegungspaket (Beispiel)
typedef struct command_move
{
BYTE bHeader; // Pakettyp
BYTE bFunc; // Bewegungsart (Gehen/Laufen/Stopp)
BYTE bArg;
BYTE bRot; // Rotation
long lX; // Zielkoordinate
long lY;
DWORD dwTime; // Client-Zeitstempel
} TPacketCGMove;
Hier zeigt das Präfix CG die Richtung des Pakets an, worauf wir gleich eingehen. Der Kernpunkt ist: Der Header allein bestimmt das Layout des Restes. In dem Moment, in dem der Server bHeader liest, liest er genau sizeof(TPacketCGMove) Bytes und kopiert sie direkt in die Struktur.
CG, GC und die prozessübergreifende Header-Benennung
Metin2 besteht aus mehreren Prozessen (auth, db, game/core), und Paketnamen kodieren ihre Richtung mit zwei Buchstaben. Diese Konvention ist der schnellste Weg, sich im Quellcode zu orientieren:
- CG — Client to Game: vom Client zum Spielkern (Bewegung, Angriff, Chat, Gegenstandsnutzung).
- GC — Game to Client: vom Spielkern zum Client (Figur hinzufügen, HP-Aktualisierung, Chat-Broadcast).
- GD / DG — Kommunikation zwischen Spiel und DB-Prozess (Spieler laden und speichern).
- GG — Game to Game: Peer-to-Peer-Nachrichten zwischen Cores (Kanälen).
So ist HEADER_CG_ATTACK das vom Client gesendete Angriffspaket, während HEADER_GC_CHARACTER_ADD das Paket ist, das der Server an den Client schickt, um eine neue Figur auf dem Bildschirm zu platzieren. Header-Konstanten werden üblicherweise in einem enum gehalten:
enum
{
HEADER_CG_HANDSHAKE = 253,
HEADER_CG_LOGIN = 1,
HEADER_CG_ATTACK = 2,
HEADER_CG_MOVE = 3,
HEADER_CG_CHAT = 4,
// ...
};
Die numerischen Werte variieren je nach Version und Quelle und können sich zwischen Private-Server-Bases unterscheiden. Entscheidend ist nicht die Zahl selbst, sondern dass Client und Server dieselbe Header-Tabelle teilen. Verwenden beide Seiten unterschiedliche Werte, werden Pakete falsch interpretiert.
Pakete mit fester und dynamischer Größe
Die überwiegende Mehrheit der Pakete hat eine feste Größe: Ist der Header gelesen, ist die Größe der Struktur bereits bekannt. Bewegungs-, Angriffs- und HP-Aktualisierungspakete gehören zu dieser Gruppe. Eine Chat-Nachricht, Gegenstandsnamen oder ein Paket mit einer Liste variabler Länge passen jedoch nicht in eine vorab bestimmte Größe. Das sind dynamische Pakete und sie tragen direkt nach dem Header ein WORD size-Feld:
#pragma pack(1)
// Client -> Game: Chat-Paket (dynamisch)
typedef struct command_chat
{
BYTE bHeader; // HEADER_CG_CHAT
WORD wSize; // Gesamtlänge des kompletten Pakets
BYTE bType; // normal / Gruppe / Gilde ...
// gefolgt von Textbytes bis wSize
} TPacketCGChat;
Der Server liest zuerst den Header und dann das wSize-Feld, um die Gesamtlänge des Pakets zu erfahren. Der Nachrichtentext ergibt sich, indem man den festen Teil der Struktur von dieser Größe abzieht. Dynamische Pakete werden daher in zwei Schritten gelesen: zuerst der feste Kopf, dann der Rumpf bis wSize.
Verbindungsablauf: vom Handshake bis ins Spiel
Der Client beginnt nicht sofort, Pakete zu senden. Die Verbindung durchläuft mehrere Phasen, und in jeder Phase werden nur die zu dieser Phase gehörenden Pakete akzeptiert. Ein typischer Ablauf sieht so aus:
- Handshake: Nach dem Aufbau der TCP-Verbindung sendet der Server ein Handshake-Paket. Client und Server tauschen mehrfach Zeitstempel hin und her, um die Netzwerklatenz zu messen und ihre Uhren zu synchronisieren.
- Login / Auth: Sobald die Zeitsynchronisation präzise genug ist, sendet der Client das Login-Paket (mit seinem Sitzungsschlüssel).
- Select: Die Figurenliste wird gesendet und der Spieler wählt seine Figur.
- Loading: Karte und Figurendaten werden geladen; der Client signalisiert, dass er bereit ist.
- Game: Die eigentliche Spielphase. Bewegungs-, Kampf- und Chat-Pakete fließen nun frei.
Die Handshake-Phase ist besonders wichtig: Da Metin2 Bewegungen und Angriffe gegen Zeitstempel validiert, müssen die Uhren von Client und Server nahe beieinanderliegen. Handshake-Pakete werden wiederholt, bis die Round-Trip-Messung unter einen akzeptablen Schwellenwert fällt.
Wie liest der Server ein Paket?
Auf der Serverseite hat jede Verbindung einen Puffer und einen Input-Prozessor, der zu ihrer aktuellen Phase passt. Da TCP stream-basiert ist, treffen Bytes in Fragmenten ein; der Server darf niemals ein unvollständiges Paket verarbeiten. Die Parse-Schleife sieht ungefähr so aus:
- Vom Socket eintreffende Bytes werden an den Puffer der Verbindung angehängt.
- Enthält der Puffer mindestens 1 Byte, wird der Header gelesen (ohne ihn schon aus dem Puffer zu verbrauchen).
- Der Header bestimmt den Pakettyp und die erwartete Größe. Ist er dynamisch, wird auch
wSizegelesen. - Enthält der Puffer noch nicht das ganze Paket, stoppt die Schleife und wartet auf weitere Bytes.
- Ist das vollständige Paket da, wird es an seinen Handler übergeben und diese Bytes werden verbraucht.
// Kern der Parse-Schleife (Pseudocode)
while (buffer.size() >= 1)
{
BYTE header = buffer.peek_byte(0);
int packetSize = GetPacketSize(header); // aus wSize, falls dynamisch
if (buffer.size() < packetSize)
break; // Paket noch nicht vollständig
Dispatch(header, buffer.read(packetSize)); // an den Handler weiterleiten
}
Ist der Header ein unbekannter Wert (nicht in der Header-Tabelle), wird das in der Regel als Protokollverletzung gewertet und die Verbindung geschlossen; in vielen Private-Server-Bases ist das eine zusätzliche Verteidigungslinie, um Cheating oder Manipulation zu erkennen.
Verschlüsselung und ein Sicherheitshinweis
In den frühesten Metin2-Versionen wurden Pakete im Klartext (unverschlüsselt) gesendet. Spätere Versionen und die heutigen Private Server führen während des Handshakes einen Schlüsselaustausch durch, um den Paketrumpf zu verschlüsseln. Das erschwert Packet-Sniffing und das Einschleusen gefälschter Pakete. Dennoch darf der Server dem Client niemals vertrauen: Jedes Feld eines eingehenden Pakets (Koordinaten, Schaden, Menge) muss serverseitig validiert werden. Einen vom Client kontrollierten Wert blind zu akzeptieren ist die Hauptursache für Exploits wie Speedhacks, Teleportieren und Item-Duplizierung (Dupe).
Häufige Fragen
Verwenden Metin2-Pakete TCP oder UDP?
Der Spielverkehr läuft über TCP. Alle Pakete, einschließlich Bewegung und Kampf, erfordern eine zuverlässige, geordnete Zustellung, daher wird stream-basiertes TCP verwendet; deshalb ist das Ansammeln unvollständiger Pakete im Serverpuffer normal und muss korrekt behandelt werden.
Warum ist der Header ein einzelnes Byte, und reicht das?
Ein einzelnes BYTE adressiert 256 unterschiedliche Pakettypen, was für das Kern-Spielprotokoll mehr als genug ist. Werden mehr Untertypen benötigt, fügt man im Paket ein zusätzliches bSubHeader- oder bType-Feld ein, sodass das einzelne Header-Byte erhalten bleibt, während sich die Typen erweitern.
Worauf muss ich achten, wenn ich mein eigenes Paket hinzufüge?
Definiere die Header-Konstante auf Client- und Serverseite mit demselben Wert, packe die Struktur mit #pragma pack(1), entscheide anhand von fest oder dynamisch, ob du ein wSize brauchst, und validiere im Server-Handler stets jedes eingehende Feld.
Möchten Sie auf Ihrem Metin2-Server ein eigenes Paketsystem entwickeln oder einen Fehler auf Protokollebene beheben? Lassen Sie uns die Client-Server-Kommunikation gemeinsam durchgehen und eine solide Lösung aufsetzen. Kontaktieren Sie mich.