Skip to content
Lesson 9.6Network Messages, Authority, and IPC

Clients, Handshakes, and Chat Automation

See a complete protocol client perform Wesnoth's local handshake, read framed messages, and respond to one chat command.

Lesson 9.6
Read or listenPrint chapter or book
About 24 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← β†’
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 9.6Key ideas, reference and questions

A one-page summary of β€œClients, Handshakes, and Chat Automation” you can keep beside your notes. Plain Markdown.

Lesson 9.6 β€” Clients, Handshakes, and Chat Automation: cheatsheet

  • Remember: A timeout bounds how long the bot can wait; a verified endpoint bounds whom it talks to.
  • Also useful: Treat the connection like a conversation: inspect the reply before deciding which question is appropriate next.

Reading map

  • What we are building
  • Connect with timeouts
  • Read and write exact frames
  • Handle early server replies
  • Perform the Wesnoth 1.14.9 login
  • Answer \wave
  • Prove the bot works
  • Keep it well behaved
  • Run the complete bot

Review

  • Q: What should a local chat bot do before acting on a decoded command? A: Validate framing, size, syntax, and the few allowed commands
  • Q: Why must a client's handshake follow a fixed order, reading each reply before sending the next message? A: Each reply confirms a stage is done, and the server expects the next only then
Download cheatsheet (.md)

Run wesnothd.exe on your own computer, connect a client named ChatBot, and make it answer \wave with Hello! in the lobby.

This is not a toy protocol. It uses the exact flow we reversed from Wesnoth 1.14.9:

wesnothdChatBotwesnothdChatBotloop[lobby]connect 127.0.0.1:15000four zero bytesnegotiation replygzip-framed [version]version replygzip-framed [login]login replygzip-framed WML

Keep transport, decoding, session meaning, and behavior as separate layers. One incoming message should travel through the pipeline before any reply is built:

A parsed message still needs session context. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

read the selected inputs
evaluate each stated condition
combine the required conditions
perform the effect only when accepted
otherwise retain the old state
report the result and its limits

Result: one greeting reply is prepared for serialization, compression and framing.

Final values: phase = lobby; message = wave; in_lobby = true; text_matches = true; accepted = true; reply = Hello! from ChatBot.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    phase = lobby; message = wave.

  2. Step 2, line 2. Valid text alone does not establish that a chat action belongs in this phase.

    phase = lobby; message = wave; in_lobby = true; text_matches = true.

  3. Step 3, line 3. Accept only when the required conditions all hold.

    phase = lobby; message = wave; in_lobby = true; text_matches = true; accepted = true.

  4. Step 4, line 4. The model performs the accepted effect.

    phase = lobby; message = wave; in_lobby = true; text_matches = true; accepted = true; reply = Hello! from ChatBot.

  5. Step 5, line 6. Result: one greeting reply is prepared for serialization, compression and framing.

    phase = lobby; message = wave; in_lobby = true; text_matches = true; accepted = true; reply = Hello! from ChatBot.

Watch it happenTurn a lobby command into a framed reply

A bounded gzip frame expands into a WML message whose text is backslash wave and room is lobby. The validated chat event reaches a handler, which creates Hello! from ChatBot. The reply is serialized, compressed and framed with a four-byte length before entering TCP.

Read the steps
  1. Read the four-byte big-endian length, enforce the frame limit, and keep collecting until the whole gzip payload is available.
  2. Decompress the complete payload. The WML text now has message and room fields; compressed bytes are no longer passed to the behavior layer.
  3. Parse and validate the WML. The client is already in the lobby, where this chat message is permitted. Build an owned Chat event containing the text \wave.
  4. Pass the event to the command handler. Its text matches \wave; ordinary chat would finish here without producing a reply.
  5. The bot constructs Hello! with room lobby and sender ChatBot. These fields are new outbound data; the received frame is not copied back unchanged.
  6. Serialize the reply as WML, compress it with gzip, and place its byte count in a four-byte big-endian header. The encoder supplies the actual compressed length.
  7. Write all bytes of that new frame to the TCP stream. The header and complete compressed reply leave together; transport can still split them across reads.

This shows the layered client design. The compact prototype below recognizes its command with a text test.

This separation lets a malformed frame fail before it reaches chat behavior, and it lets the command handler work with events instead of raw network bytes:

A well-formed command reaches the handlerEach layer passes on only what it has checked: a complete frame, then named fields, then an event.
  1. Data u32 length, then that many bytes

    Step 1: TCP stream to read_frame

    read_frame reads the four-byte big-endian length, checks it against MAX_FRAME (1,048,576), then reads exactly that many bytes.

  2. Data one complete gzip payload

    Step 2: read_frame to Decoder

    Only a complete frame is passed on; a partial read never reaches the decoder.

  3. Data Chat event: text "\wave"

    Step 3: Decoder to Command handler

    gunzip and the WML parser turn bytes into named fields, so the handler receives an event, not bytes.

  4. Reply new framed reply: Hello!

    Step 4: Command handler to TCP stream

    The text matches \wave, so the handler builds new reply fields; they are serialized, compressed and framed before writing.

Each layer passes on only what it has checked: a complete frame, then named fields, then an event.

A malformed payload stops at the decoderThe frame is complete, but its bytes are not valid gzip. The error ends the chain before any behavior runs.
  1. Data u32 length, then that many bytes

    Step 1: TCP stream to read_frame

    The length is within MAX_FRAME and all its bytes arrive, so framing succeeds.

  2. Data one complete payload

    Step 2: read_frame to Decoder

    The decoder receives the whole payload.

  3. Alone gunzip fails: return an error

    Step 3: Decoder (alone)

    No Chat event is built, so the command handler never runs and no reply is sent.

The frame is complete, but its bytes are not valid gzip. The error ends the chain before any behavior runs.

Before reading protocol bytes, the client needs a connection that cannot silently drift to another host or wait forever for a stalled local server. connect_local accepts one socket address, checks that it is the course loopback endpoint, then sets finite connect, read, and write waits:

use std::{
io::{self, Read, Write},
net::{SocketAddr, TcpStream},
time::Duration,
};
const MAX_FRAME: usize = 1_048_576;
fn connect_local(address: SocketAddr) -> io::Result<TcpStream> {
if !address.ip().is_loopback() || address.port() != 15_000 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"this Wesnoth lab connects only to loopback port 15000",
));
}
let stream = TcpStream::connect_timeout(&address, Duration::from_secs(3))?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
Ok(stream)
}

The timeout prevents a broken server or parser from hanging the bot forever.

Lesson 9.4’s encode_wesnoth_frame performs the gzip compression and adds the four-byte big-endian length. write_all matters because one write may send only part of a buffer.

byte 0-3`u32` length, big-endian
byte 4..gzip-compressed WML, that many bytes
The frame read_frame parses below. It trusts only the four-byte header at first, then reads exactly length more bytes before decompressing.

The two helpers implement the strip in opposite directions. send_wml frames text and writes all bytes; read_frame reads the four-byte length, checks the cap, then reads that many payload bytes before a decoder sees them. A short TCP read is normal, so both paths use the exact-read/write operations:

fn send_wml(stream: &mut TcpStream, wml: &str) -> anyhow::Result<()> {
let frame = encode_wesnoth_frame(wml)?;
stream.write_all(&frame)?;
stream.flush()?;
Ok(())
}
fn read_frame(stream: &mut TcpStream) -> io::Result<Vec<u8>> {
let mut header = [0_u8; 4];
stream.read_exact(&mut header)?;
let length = u32::from_be_bytes(header) as usize;
if length > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
let mut frame = Vec::with_capacity(4 + length);
frame.extend_from_slice(&header);
frame.resize(4 + length, 0);
stream.read_exact(&mut frame[4..])?;
Ok(frame)
}

read_exact handles TCP fragmentation. It does not assume one receive call equals one Wesnoth message.

The opening negotiation is special, so collect its replies until a short quiet period. The historical capture received 41 bytes.

Ok(0)
Ok(count) > 0
WouldBlock or TimedOut
other error
stream.read(&mut chunk)
result?
connection closed: stop
append to collected
quiet period: stop
return the error

This quiet-period reader is only for the unframed negotiation reply. It collects successive TCP reads until the server closes or pauses briefly, while the size cap prevents an unexpected reply from growing forever. After login, read_frame uses the protocol’s length field instead:

fn read_server_batch(stream: &mut TcpStream) -> io::Result<Vec<u8>> {
stream.set_read_timeout(Some(Duration::from_millis(250)))?;
let mut collected = Vec::new();
let mut chunk = [0_u8; 512];
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(count) => {
collected.extend_from_slice(&chunk[..count]);
if collected.len() > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidData, "reply too large"));
}
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => break,
Err(error) => return Err(error),
}
}
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
Ok(collected)
}

Keep this helper for negotiation only. Once the session is in the lobby, use the frame reader.

Windows VM

The parser and frame writer now have a concrete job. The course server first expects four zero negotiation bytes, then a version message and a login message in compressed WML frames. log_in sends them in that order and reads each reply before advancing; a nickname is validated before it enters WML:

fn log_in(stream: &mut TcpStream, nickname: &str) -> anyhow::Result<()> {
let nickname = simple_field(nickname)?;
// The first message is not compressed or length-prefixed.
stream.write_all(&[0, 0, 0, 0])?;
let reply = read_server_batch(stream)?;
anyhow::ensure!(!reply.is_empty(), "server did not negotiate");
send_wml(
stream,
"[version]\nversion=\"1.14.9\"\n[/version]",
)?;
let _version_reply = read_server_batch(stream)?;
send_wml(
stream,
&format!("[login]\nusername=\"{nickname}\"\n[/login]"),
)?;
let _login_reply = read_server_batch(stream)?;
Ok(())
}

If the server rejects the version, compare the decompressed frame with the one produced by the official 1.14.9 client. Do not randomly change compressed bytes; fix the WML and recompress it.

Once the login succeeds, the client announces itself and loops over framed server messages. A decoded \wave event will produce the reply described above; ordinary messages leave the connection running:

fn run(address: SocketAddr, nickname: String) -> anyhow::Result<()> {
let mut stream = connect_local(address)?;
log_in(&mut stream, &nickname)?;
let nickname = simple_field(&nickname)?;
send_wml(
&mut stream,
&format!(
"[message]\nmessage=\"ChatBot connected\"\nroom=\"lobby\"\nsender=\"{nickname}\"\n[/message]"
),
)?;
loop {
let frame = read_frame(&mut stream)?;
let wml = decode_wesnoth_frame(&frame)?;
println!("{wml}");
if wml.contains("message=\"\\wave\"") {
send_wml(
&mut stream,
&format!(
"[message]\nmessage=\"Hello!\"\nroom=\"lobby\"\nsender=\"{nickname}\"\n[/message]"
),
)?;
}
}
}
  1. Start wesnothd.exe.
  2. Run the client with address 127.0.0.1:15000 and nickname ChatBot.
  3. Join localhost:15000 from the normal Wesnoth client.
  4. Type \wave in the lobby.
  5. Confirm Hello! appears from ChatBot.

That visible lobby response proves the real negotiation, version, login, gzip framing, Simple WML, receive loop, and command logic all work together.

Limit message length, respond at most once per command frame, add a short send rate limit, and exit cleanly when the local server closes. A parser error should print the frame size and stop; it should not enter an automatic reconnect storm.

After building the workspace, start the course wesnothd.exe on port 15000 and run:

Terminal window
.\target\i686-pc-windows-msvc\release\wesnoth_chatbot.exe ChatBot

The full client is wesnoth_chatbot.rs. The framing/parser it imports is wesnoth_protocol.rs. Together they implement the real loopback connection, timeouts, four-zero negotiation, version frame, login frame, lobby receive loop, \wave recognition, and Hello! reply.

Complete lab source: wesnoth_chatbot.rs

wesnoth_chatbot.rs

windows-labs/src/bin/ Β· Rust, 100 lines

use std::{
io::{self, Read, Write},
net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream},
time::Duration,
};
use anyhow::{Context, Result};
use gha_windows_labs::wesnoth_protocol::{
MAX_FRAME, decode_frame, read_frame, send_wml, simple_field,
};
fn read_server_batch(stream: &mut TcpStream) -> io::Result<Vec<u8>> {
stream.set_read_timeout(Some(Duration::from_millis(250)))?;
let mut collected = Vec::new();
let mut chunk = [0_u8; 512];
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(count) => {
collected.extend_from_slice(&chunk[..count]);
if collected.len() > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"reply is too large",
));
}
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
break;
}
Err(error) => return Err(error),
}
}
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
Ok(collected)
}
fn log_in(stream: &mut TcpStream, nickname: &str) -> Result<()> {
let nickname = simple_field(nickname)?;
stream.write_all(&[0, 0, 0, 0])?;
anyhow::ensure!(
!read_server_batch(stream)?.is_empty(),
"server did not negotiate"
);
send_wml(stream, "[version]\nversion=\"1.14.9\"\n[/version]")?;
anyhow::ensure!(
!read_server_batch(stream)?.is_empty(),
"server did not answer version"
);
send_wml(
stream,
&format!("[login]\nusername=\"{nickname}\"\n[/login]"),
)?;
let _login_reply = read_server_batch(stream)?;
Ok(())
}
fn main() -> Result<()> {
let nickname = std::env::args().nth(1).unwrap_or_else(|| "ChatBot".into());
simple_field(&nickname)?;
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 15_000);
let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(3))
.context("start local wesnothd.exe on port 15000 first")?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
log_in(&mut stream, &nickname)?;
send_wml(
&mut stream,
&format!(
"[message]\nmessage=\"ChatBot connected\"\nroom=\"lobby\"\nsender=\"{nickname}\"\n[/message]"
),
)?;
println!("{nickname} joined the local Wesnoth lobby. Type \\wave from a normal client.");
loop {
let frame = match read_frame(&mut stream) {
Ok(frame) => frame,
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
Err(error) => return Err(error.into()),
};
let wml = decode_frame(&frame)?;
println!("{wml}");
if wml.contains("message=\"\\wave\"") {
send_wml(
&mut stream,
&format!(
"[message]\nmessage=\"Hello!\"\nroom=\"lobby\"\nsender=\"{nickname}\"\n[/message]"
),
)?;
}
}
Ok(())
}

The reusable gzip/WML framing stays in one tested protocol module; this file owns the session state. That split is intentional: byte framing should not know what \wave means, and chat behavior should not reimplement gzip.

An external client answering Wesnoth chatSilent demonstration Β· 11 seconds

The original external-client demonstration shows a chat command receiving a reply. The visible exchange depends on the earlier connection, negotiation, login, frame decoding, and command handling; a valid message alone is not a complete client.

Download An external client answering Wesnoth chat video

This bot owns its own server connection. Lesson 9.7 puts a relay between the official client and the same local server, so we can observe an existing conversation before choosing one bounded change.

Knowledge check

Treat protocol input as untrusted

What should a local chat bot do before acting on a decoded command?

Finished Lesson 9.6?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 9: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.