Clients, Handshakes, and Chat Automation
See a complete protocol client perform Wesnoth's local handshake, read framed messages, and respond to one chat command.
Cheatsheet for Lesson 9.6
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
What we are building
Section titled βWhat we are buildingβ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:
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 limitsResult: 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
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
phase = lobby; message = wave.
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.
Step 3, line 3. Accept only when the required conditions all hold.
phase = lobby; message = wave; in_lobby = true; text_matches = true; accepted = true.
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.
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.
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
- Read the four-byte big-endian length, enforce the frame limit, and keep collecting until the whole gzip payload is available.
- Decompress the complete payload. The WML text now has message and room fields; compressed bytes are no longer passed to the behavior layer.
- 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.
- Pass the event to the command handler. Its text matches \wave; ordinary chat would finish here without producing a reply.
- The bot constructs Hello! with room lobby and sender ChatBot. These fields are new outbound data; the received frame is not copied back unchanged.
- 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.
- 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:
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.
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.
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.
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.
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.
Data one complete payload
Step 2: read_frame to Decoder
The decoder receives the whole payload.
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.
Connect with timeouts
Section titled βConnect with timeoutsβ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)}Read and write exact frames
Section titled βRead and write exact framesβ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.
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.
Handle early server replies
Section titled βHandle early server repliesβThe opening negotiation is special, so collect its replies until a short quiet period. The historical capture received 41 bytes.
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.
Perform the Wesnoth 1.14.9 login
Section titled βPerform the Wesnoth 1.14.9 loginβ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.
Answer \wave
Section titled βAnswer \waveβ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]" ), )?; } }}Prove the bot works
Section titled βProve the bot worksβ- Start
wesnothd.exe. - Run the client with address
127.0.0.1:15000and nicknameChatBot. - Join
localhost:15000from the normal Wesnoth client. - Type
\wavein the lobby. - 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.
Keep it well behaved
Section titled βKeep it well behavedβ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.
Run the complete bot
Section titled βRun the complete botβAfter building the workspace, start the course wesnothd.exe on port 15000 and run:
.\target\i686-pc-windows-msvc\release\wesnoth_chatbot.exe ChatBotThe 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
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.
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 videoThis 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.
Treat protocol input as untrusted
What should a local chat bot do before acting on a decoded command?
Network data is untrusted even in a lab. Bounded parsing and an explicit command enum keep data from turning into unintended behavior.
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.
Plain Markdown, saved in this browser.
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.