use super::*;
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum NetMsg {
Hello {
name: WireName,
},
Watch,
Input(InputMsg),
Hash {
frame: u32,
hash: u64,
},
Start {
seats: u8,
seat: Option<u8>,
terms: MatchTerms,
names: [WireName; crate::sim::MAX_PLAYERS],
round: u8,
wins: [u8; crate::sim::MAX_PLAYERS],
beach: Vec<u8>,
},
Pause {
frame: u32,
},
Resume {
frame: u32,
},
Abandoned {
seat: u8,
frame: u32,
},
Roster {
seats: u8,
names: [WireName; crate::sim::MAX_PLAYERS],
terms: MatchTerms,
},
Chat {
name: WireName,
text: WireChat,
},
Queued {
ahead: u8,
},
Incompatible {
version: u8,
},
}
impl NetMsg {
pub fn hello(name: &str) -> NetMsg {
NetMsg::Hello {
name: wire_name(name),
}
}
}
const TAG_HELLO: u8 = 0;
const TAG_INPUT: u8 = 1;
const TAG_HASH: u8 = 2;
const TAG_START: u8 = 3;
const TAG_PAUSE: u8 = 4;
const TAG_RESUME: u8 = 5;
const TAG_WATCH: u8 = 6;
const TAG_INCOMPATIBLE: u8 = 7;
const TAG_QUEUED: u8 = 8;
const TAG_CHAT: u8 = 9;
const TAG_ROSTER: u8 = 10;
const TAG_ABANDONED: u8 = 11;
const HIGHEST_TAG: u8 = TAG_ABANDONED;
const SPECTATOR_SEAT: u8 = u8::MAX;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct MatchTerms {
pub bots: u8,
pub bot_level: u8,
pub map: u8,
pub gulls: u8,
pub round: u8,
pub teams: u8,
pub seed: u64,
pub series: u8,
}
impl MatchTerms {
const BYTES: usize = 15;
fn encode(self) -> [u8; Self::BYTES] {
let mut out = [0u8; Self::BYTES];
out[0] = self.bots;
out[1] = self.bot_level;
out[2] = self.map;
out[3] = self.gulls;
out[4] = self.round;
out[5] = self.teams;
out[6..14].copy_from_slice(&self.seed.to_le_bytes());
out[14] = self.series;
out
}
fn decode(bytes: &[u8]) -> Option<MatchTerms> {
let seed = u64::from_le_bytes(bytes.get(6..14)?.try_into().ok()?);
Some(MatchTerms {
bots: *bytes.first()?,
bot_level: *bytes.get(1)?,
map: *bytes.get(2)?,
gulls: *bytes.get(3)?,
round: *bytes.get(4)?,
teams: *bytes.get(5)?,
seed,
series: *bytes.get(14)?,
})
}
}
impl NetMsg {
pub fn encode(self) -> Vec<u8> {
let mut bytes = match self {
NetMsg::Hello { .. } => vec![TAG_HELLO],
NetMsg::Watch => vec![TAG_WATCH],
NetMsg::Queued { .. } => vec![TAG_QUEUED],
NetMsg::Chat { .. } => vec![TAG_CHAT],
NetMsg::Roster { .. } => vec![TAG_ROSTER],
NetMsg::Abandoned { .. } => vec![TAG_ABANDONED],
NetMsg::Input(_) => vec![TAG_INPUT],
NetMsg::Hash { .. } => vec![TAG_HASH],
NetMsg::Start { .. } => vec![TAG_START],
NetMsg::Pause { .. } => vec![TAG_PAUSE],
NetMsg::Resume { .. } => vec![TAG_RESUME],
NetMsg::Incompatible { version } => return vec![TAG_INCOMPATIBLE, version],
};
bytes.push(PROTOCOL_VERSION);
match self {
NetMsg::Watch | NetMsg::Incompatible { .. } => {}
NetMsg::Resume { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
NetMsg::Queued { ahead } => bytes.push(ahead),
NetMsg::Abandoned { seat, frame } => {
bytes.push(seat);
bytes.extend_from_slice(&frame.to_le_bytes());
}
NetMsg::Chat { name, text } => {
bytes.extend_from_slice(&name);
bytes.extend_from_slice(&text);
}
NetMsg::Roster {
seats,
names,
terms,
} => {
bytes.push(seats);
for name in names {
bytes.extend_from_slice(&name);
}
bytes.extend_from_slice(&terms.encode());
}
NetMsg::Hello { name } => bytes.extend_from_slice(&name),
NetMsg::Input(msg) => bytes.extend_from_slice(&msg.encode()),
NetMsg::Hash { frame, hash } => {
bytes.extend_from_slice(&frame.to_le_bytes());
bytes.extend_from_slice(&hash.to_le_bytes());
}
NetMsg::Start {
seats,
seat,
terms,
names,
round,
wins,
beach,
} => {
bytes.push(seats);
bytes.push(seat.unwrap_or(SPECTATOR_SEAT));
bytes.extend_from_slice(&terms.encode());
for name in names {
bytes.extend_from_slice(&name);
}
bytes.push(round);
bytes.extend_from_slice(&wins);
let len = u16::try_from(beach.len()).unwrap_or(0);
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(&beach[..usize::from(len)]);
}
NetMsg::Pause { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
}
bytes
}
pub fn peek_version(bytes: &[u8]) -> Option<u8> {
let tag = *bytes.first()?;
(tag <= HIGHEST_TAG)
.then(|| bytes.get(1).copied())
.flatten()
}
pub fn decode(bytes: &[u8]) -> Option<NetMsg> {
let tag = *bytes.first()?;
let version = *bytes.get(1)?;
if tag == TAG_INCOMPATIBLE {
return Some(NetMsg::Incompatible { version });
}
if version != PROTOCOL_VERSION {
return None;
}
let body = bytes.get(2..)?;
match tag {
TAG_HELLO => Some(NetMsg::Hello {
name: body.get(..WIRE_NAME)?.try_into().ok()?,
}),
TAG_WATCH => Some(NetMsg::Watch),
TAG_INPUT => {
let payload: [u8; INPUT_BYTES] = body.get(..INPUT_BYTES)?.try_into().ok()?;
Some(NetMsg::Input(InputMsg::decode(payload)))
}
TAG_HASH => {
let frame = u32::from_le_bytes(body.get(..4)?.try_into().ok()?);
let hash = u64::from_le_bytes(body.get(4..12)?.try_into().ok()?);
Some(NetMsg::Hash { frame, hash })
}
TAG_START => {
let terms = MatchTerms::decode(body.get(2..)?)?;
let mut names = [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS];
let table = body.get(2 + MatchTerms::BYTES..)?;
for (i, name) in names.iter_mut().enumerate() {
*name = table
.get(i * WIRE_NAME..(i + 1) * WIRE_NAME)?
.try_into()
.ok()?;
}
let (seats, seat) = (*body.first()?, *body.get(1)?);
let humans = seats.saturating_sub(terms.bots).max(1);
if !(2..=crate::sim::MAX_PLAYERS as u8).contains(&seats)
|| (seat != SPECTATOR_SEAT && seat >= humans)
{
return None;
}
let seat = (seat != SPECTATOR_SEAT).then_some(seat);
debug_assert!(seat.is_none_or(|seat| seat < seats));
let series_at = 2 + MatchTerms::BYTES + WIRE_NAME * crate::sim::MAX_PLAYERS;
let round = *body.get(series_at)?;
let wins: [u8; crate::sim::MAX_PLAYERS] = body
.get(series_at + 1..series_at + 1 + crate::sim::MAX_PLAYERS)?
.try_into()
.ok()?;
let after = series_at + 1 + crate::sim::MAX_PLAYERS;
let beach = match body.get(after..after + 2) {
Some(len) => {
let len = usize::from(u16::from_le_bytes(len.try_into().ok()?));
body.get(after + 2..after + 2 + len)?.to_vec()
}
None => Vec::new(),
};
Some(NetMsg::Start {
seats,
seat,
terms,
names,
round,
wins,
beach,
})
}
TAG_PAUSE => Some(NetMsg::Pause {
frame: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
}),
TAG_RESUME => Some(NetMsg::Resume {
frame: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
}),
TAG_QUEUED => Some(NetMsg::Queued {
ahead: *body.first()?,
}),
TAG_ABANDONED => {
let seat = *body.first()?;
let frame = u32::from_le_bytes(body.get(1..5)?.try_into().ok()?);
(seat < crate::sim::MAX_PLAYERS as u8).then_some(NetMsg::Abandoned { seat, frame })
}
TAG_ROSTER => {
let table = body.get(1..)?;
let mut names = [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS];
for (i, name) in names.iter_mut().enumerate() {
*name = table
.get(i * WIRE_NAME..(i + 1) * WIRE_NAME)?
.try_into()
.ok()?;
}
let terms = MatchTerms::decode(table.get(WIRE_NAME * crate::sim::MAX_PLAYERS..)?)?;
Some(NetMsg::Roster {
seats: *body.first()?,
names,
terms,
})
}
TAG_CHAT => Some(NetMsg::Chat {
name: body.get(..WIRE_NAME)?.try_into().ok()?,
text: body
.get(WIRE_NAME..WIRE_NAME + WIRE_CHAT)?
.try_into()
.ok()?,
}),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_message_fits_the_receive_buffer() {
let widest = super::wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
for msg in [
super::NetMsg::Hello { name: widest },
super::NetMsg::Chat {
name: widest,
text: super::wire_chat(&"W".repeat(super::CHAT_CHARS)),
},
super::NetMsg::Start {
seats: 6,
seat: Some(5),
terms: super::MatchTerms::default(),
names: [widest; crate::sim::MAX_PLAYERS],
round: 0,
wins: [0; crate::sim::MAX_PLAYERS],
beach: vec![0xAB; super::MAX_BEACH_BYTES],
},
super::NetMsg::Hash {
frame: u32::MAX,
hash: u64::MAX,
},
] {
let len = msg.clone().encode().len();
assert!(len <= super::MAX_DATAGRAM, "{len} bytes: {msg:?}");
}
}
#[test]
fn a_start_carrying_the_largest_beach_still_fits() {
let widest = super::wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
let len = super::NetMsg::Start {
seats: 6,
seat: Some(5),
terms: super::MatchTerms::default(),
names: [widest; crate::sim::MAX_PLAYERS],
round: 0,
wins: [0; crate::sim::MAX_PLAYERS],
beach: vec![0xAB; super::MAX_BEACH_BYTES],
}
.encode()
.len();
assert!(len <= super::MAX_DATAGRAM, "{len} bytes");
let spare = super::MAX_DATAGRAM - len;
assert!(spare >= 16, "only {spare} bytes of slack left");
}
#[test]
fn decode_rejects_garbage() {
assert!(super::NetMsg::decode(&[]).is_none());
assert!(super::NetMsg::decode(&[0xFF, 1, 2, 3]).is_none());
assert!(super::NetMsg::decode(b"PNCH?").is_none());
}
use crate::sim::{Direction, PlayerAction};
#[test]
fn messages_round_trip() {
for msg in [
NetMsg::hello("Anna"),
NetMsg::hello("Überlang-Name-über-die-Kappe-hinaus"),
NetMsg::Watch,
NetMsg::Resume { frame: 0 },
NetMsg::Resume { frame: 70_000 },
NetMsg::Pause { frame: 7 },
NetMsg::Input(InputMsg {
player: 1,
frame: 42,
action: PlayerAction::Place {
x: 3,
y: 8,
dir: Direction::Down,
},
}),
NetMsg::Input(InputMsg {
player: 5,
frame: 4000,
action: PlayerAction::Place {
x: 18,
y: 11,
dir: Direction::Left,
},
}),
NetMsg::Hash {
frame: 990,
hash: 0xDEAD_BEEF_0BAD_F00D,
},
NetMsg::Start {
seats: 6,
seat: Some(3),
terms: MatchTerms {
bots: 2,
teams: 1,
seed: 0x1234_5678_9ABC_DEF0,
..MatchTerms::default()
},
names: std::array::from_fn(|i| wire_name(&format!("Seat {i}"))),
round: 0,
wins: [0; crate::sim::MAX_PLAYERS],
beach: b"a handmade beach".to_vec(),
},
NetMsg::Incompatible { version: 9 },
NetMsg::Queued { ahead: 0 },
NetMsg::Queued { ahead: 4 },
NetMsg::Chat {
name: wire_name("Anna"),
text: wire_chat("wait for me!"),
},
NetMsg::Roster {
seats: 4,
names: std::array::from_fn(|i| wire_name(&format!("P{i}"))),
terms: MatchTerms {
bots: 1,
map: 3,
..MatchTerms::default()
},
},
NetMsg::Abandoned { seat: 0, frame: 0 },
NetMsg::Abandoned {
seat: crate::sim::MAX_PLAYERS as u8 - 1,
frame: 123_456,
},
] {
assert_eq!(NetMsg::decode(&msg.clone().encode()), Some(msg));
}
}
#[test]
fn a_start_that_seats_more_than_the_table_is_refused() {
let good = NetMsg::Start {
seats: 6,
seat: Some(5),
terms: MatchTerms::default(),
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
round: 0,
wins: [0; crate::sim::MAX_PLAYERS],
beach: Vec::new(),
};
assert_eq!(NetMsg::decode(&good.clone().encode()), Some(good.clone()));
for (seats, seat) in [(7, 0), (255, 0), (1, 0), (0, 0), (4, 4), (4, 200)] {
let mut bytes = good.clone().encode();
bytes[2] = seats;
bytes[3] = seat;
assert_eq!(NetMsg::decode(&bytes), None, "{seats} seats, sat at {seat}");
}
let mut watching = good.clone().encode();
watching[3] = SPECTATOR_SEAT;
assert!(matches!(
NetMsg::decode(&watching),
Some(NetMsg::Start { seat: None, .. })
));
let with_bots = |seats, bots, seat| {
NetMsg::decode(
&NetMsg::Start {
seats,
seat: Some(seat),
terms: MatchTerms {
bots,
..MatchTerms::default()
},
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
round: 0,
wins: [0; crate::sim::MAX_PLAYERS],
beach: Vec::new(),
}
.encode(),
)
};
assert!(with_bots(6, 4, 1).is_some(), "the last human seat");
assert!(with_bots(6, 4, 2).is_none(), "the first AI seat");
assert!(with_bots(6, 4, 3).is_none());
assert!(with_bots(6, 6, 1).is_none(), "more bots than chairs");
assert!(with_bots(6, 6, 0).is_some(), "seat zero always stands");
}
#[test]
fn another_build_is_refused_and_told_so() {
let mut hello = NetMsg::hello("Anna").encode();
assert_eq!(hello[1], PROTOCOL_VERSION, "the version byte is byte 1");
hello[1] = PROTOCOL_VERSION.wrapping_add(1);
assert_eq!(NetMsg::decode(&hello), None, "not ours, so not acted on");
assert_eq!(
NetMsg::peek_version(&hello),
Some(PROTOCOL_VERSION.wrapping_add(1)),
"but it can still be identified, which is what gets it answered"
);
let refusal = NetMsg::Incompatible { version: 77 }.encode();
assert_eq!(refusal.len(), 2, "its layout is frozen at two bytes");
assert_eq!(
NetMsg::decode(&refusal),
Some(NetMsg::Incompatible { version: 77 })
);
assert_eq!(NetMsg::peek_version(&[0xFE, 3]), None);
assert_eq!(NetMsg::peek_version(b"PNCH1"), None, "an announcement");
}
}
#[cfg(test)]
mod wire_fuzz_probe {
use super::*;
#[test]
fn no_datagram_can_break_a_decoder() {
let mut rng = crate::sim::Pcg32::new(0xDEAD_BEEF, 0x1357);
let mut seeds: Vec<Vec<u8>> = vec![
NetMsg::hello("Anna").encode(),
NetMsg::Watch.encode(),
NetMsg::Queued { ahead: 3 }.encode(),
NetMsg::Chat {
name: wire_name("Bo"),
text: wire_chat("ready?"),
}
.encode(),
NetMsg::Start {
seats: 6,
seat: Some(2),
terms: MatchTerms::default(),
names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
round: 0,
wins: [0; crate::sim::MAX_PLAYERS],
beach: Vec::new(),
}
.encode(),
NetMsg::Hash { frame: 7, hash: 9 }.encode(),
ANNOUNCE_MAGIC.to_vec(),
Vec::new(),
];
let mut beacon = ANNOUNCE_MAGIC.to_vec();
beacon.extend_from_slice(&49213u16.to_le_bytes());
beacon.push(BEACON_RUNNING);
beacon.extend_from_slice(&wire_name("Room 3"));
beacon.push(4);
beacon.push(6);
beacon.extend_from_slice(&0x5EA5u64.to_le_bytes());
seeds.push(beacon);
for round in 0..80_000u32 {
let mut bytes = seeds[(round as usize) % seeds.len()].clone();
for _ in 0..(rng.next_u32() % 6) + 1 {
if bytes.is_empty() {
bytes.push((rng.next_u32() % 256) as u8);
continue;
}
let at = (rng.next_u32() as usize) % bytes.len();
match rng.next_u32() % 4 {
0 => bytes[at] = (rng.next_u32() % 256) as u8,
1 => drop(bytes.remove(at)),
2 => bytes.insert(at, (rng.next_u32() % 256) as u8),
_ => bytes.truncate(at),
}
}
if let Some(msg) = NetMsg::decode(&bytes) {
assert_eq!(
NetMsg::decode(&msg.clone().encode()),
Some(msg.clone()),
"{bytes:?}"
);
if let NetMsg::Start { seats, seat, .. } = msg {
assert!((2..=crate::sim::MAX_PLAYERS as u8).contains(&seats));
assert!(seat.is_none_or(|seat| seat < seats));
}
if let NetMsg::Abandoned { seat, .. } = msg {
assert!(usize::from(seat) < crate::sim::MAX_PLAYERS);
}
}
let _ = NetMsg::peek_version(&bytes);
let _ = beacon_name(&bytes, bytes.len(), BEACON_NAME_AT);
let _ = beacon_name(&bytes, bytes.len(), BEACON_HOST_AT);
}
}
}