pinch_points/transport/msg.rs
1//! What one datagram says, and the bytes it says it in.
2//!
3//! Byte 0 tags the message and byte 1 is the [`PROTOCOL_VERSION`], frozen
4//! there for all time so a build can tell "I cannot read this" from "I
5//! disagree with this". Adding a tag is a version bump exactly as much as
6//! changing a layout is, and it is the half that gets forgotten, because
7//! nothing stops compiling when you do.
8
9use super::*;
10
11#[derive(Clone, PartialEq, Eq, Debug)]
12pub enum NetMsg {
13 /// Handshake ping; the host learns the peer's address (and what to
14 /// call them) from it.
15 Hello {
16 name: WireName,
17 },
18 /// Handshake ping from a peer that wants to watch, not play. Repeated
19 /// like `Hello` until a `Start` lands.
20 Watch,
21 Input(InputMsg),
22 /// State fingerprint after `frame`, for loud desync detection.
23 Hash {
24 frame: u32,
25 hash: u64,
26 },
27 /// Host → joiner: the match begins with `seats` seats on `terms`; you are
28 /// `seat`, and the table is called `names` (empty entries fall back to
29 /// seat labels). Re-sent whenever a joiner is still saying hello.
30 Start {
31 seats: u8,
32 /// The seat this peer is given, or `None` for a peer that came to
33 /// watch. On the wire that `None` is [`SPECTATOR_SEAT`], a number
34 /// outside the range of real seats. In memory it is an absence,
35 /// which is what a watcher is, and what the launch plan beside it
36 /// has called one all along.
37 seat: Option<u8>,
38 terms: MatchTerms,
39 names: [WireName; crate::sim::MAX_PLAYERS],
40 /// Where the series stands as this round begins: its 1-based
41 /// number, and the rounds each *seat* has won so far. Zero and
42 /// empty for a single round.
43 ///
44 /// The host says, because seats move: a peer that leaves between
45 /// rounds frees its chair and everyone behind it moves up one, so
46 /// a tally each peer kept by seat number credited the departed
47 /// player's rounds to whoever moved into the seat. The host holds
48 /// the mapping and re-deals the tally with the chairs; a peer
49 /// admitted from the queue mid-series learns the standings the
50 /// same way, rather than starting a series of its own.
51 round: u8,
52 wins: [u8; crate::sim::MAX_PLAYERS],
53 /// The beach itself, when the host picked one it built rather than
54 /// one both peers already have. A generated arena travels as a
55 /// seed; a handmade one has to travel as itself, because the
56 /// joiner has never seen the file. Empty for the built-in maps.
57 ///
58 /// Compressed with the same coder the share codes use, and it has
59 /// to be: a 20x13 beach is fifteen hundred characters of text and
60 /// the datagram is a kilobyte.
61 beach: Vec<u8>,
62 },
63 /// Someone hit pause: everybody stops committing at `frame`. Re-sent
64 /// every tick while paused, so a dropped datagram costs a moment of
65 /// confusion rather than a stuck match.
66 Pause {
67 frame: u32,
68 },
69 /// Play on: the pause that was to freeze on `frame` is lifted. Also
70 /// re-sent until the sim visibly moves again. The frame is what lets a
71 /// peer tell the `Pause` echoes still in flight from before the resume
72 /// (see `Lockstep::receive_pause`) from a fresh pause.
73 Resume {
74 frame: u32,
75 },
76 /// Host → the table: `seat` has stopped sending and an AI is taking
77 /// the chair.
78 ///
79 /// Only the host says so, and everyone acts on its word rather than on
80 /// their own patience. A peer that decided for itself would fill the
81 /// seat on whichever frame its own timer happened to run out, and two
82 /// peers filling it on different frames is a desync, which lockstep has
83 /// no way to recover from.
84 ///
85 /// `frame` is the one the host was held up on, and every peer empties
86 /// the seat from there: they do not all hold the same inputs from a
87 /// player who has gone quiet (the host relays each as it arrives, and
88 /// a peer that missed the relay of one may hold a later one), so
89 /// "from the frame you are stuck on" is not the same frame everywhere.
90 /// Repeated for the rest of the round, since a lost one leaves that
91 /// peer frozen while the others play on.
92 Abandoned {
93 seat: u8,
94 frame: u32,
95 },
96 /// Host → the lobby: who is at the table right now, in seat order.
97 ///
98 /// A joiner has only ever spoken to the host, so without this it knows
99 /// nobody else is even there until the match starts. Re-sent whenever
100 /// the table changes and on a timer besides, since a roster that went
101 /// missing would leave a screen wrong for good rather than briefly.
102 Roster {
103 seats: u8,
104 names: [WireName; crate::sim::MAX_PLAYERS],
105 /// The host's dials as they stand, so a joiner's card shows the
106 /// match it is joining rather than its own setup screen's idea of
107 /// one. Everything but the seed is meaningful before the launch.
108 terms: MatchTerms,
109 },
110 /// A line said in the lobby, and who said it. The sender names itself
111 /// rather than the host stamping it: a joiner's greeting is the only
112 /// other place the host learns a name, and a peer that never greeted
113 /// would otherwise speak anonymously.
114 ///
115 /// Relayed by the host to the rest of the table, like an input: the
116 /// spokes of the star cannot hear each other.
117 Chat {
118 name: WireName,
119 text: WireChat,
120 },
121 /// Host → a peer that turned up after the launch: the round is under
122 /// way and cannot take you, but you are in line for the next one, with
123 /// `ahead` people in front of you.
124 ///
125 /// The answer to a greeting that used to be met with a spectator seat,
126 /// which was worse than useless: lockstep replays from frame zero, so
127 /// such a peer built a board nobody would ever send it inputs for and
128 /// sat there, apparently connected, forever.
129 Queued {
130 ahead: u8,
131 },
132 /// "I speak protocol `version`, and what you sent me is not it." The
133 /// answer to a datagram from another build, so a mismatched joiner is
134 /// told why nothing is happening instead of greeting a host that ignores
135 /// it forever.
136 ///
137 /// The one message exempt from the version gate, and the one whose layout
138 /// is frozen along with the version byte: every build, past and future,
139 /// can read `[TAG_INCOMPATIBLE, version]`.
140 Incompatible {
141 version: u8,
142 },
143}
144
145impl NetMsg {
146 /// A greeting carrying `name` in wire form.
147 pub fn hello(name: &str) -> NetMsg {
148 NetMsg::Hello {
149 name: wire_name(name),
150 }
151 }
152}
153
154// Byte 0 of every datagram.
155//
156// Adding a line here is a `PROTOCOL_VERSION` bump, exactly as much as
157// changing the layout of an existing message is, and it is the half that
158// gets forgotten, because nothing stops compiling when you do. Two builds
159// both claiming the same version, one of them sending a tag the other has
160// never heard of, is the silent disagreement that byte exists to prevent:
161// the older one reads it as noise and simply never acts on it.
162// Chat, the roster and the abandonment notice all shipped under version 4
163// before anybody noticed.
164//
165// So a new tag is three lines, not one: the tag, `HIGHEST_TAG` below it,
166// and the version.
167const TAG_HELLO: u8 = 0;
168const TAG_INPUT: u8 = 1;
169const TAG_HASH: u8 = 2;
170const TAG_START: u8 = 3;
171const TAG_PAUSE: u8 = 4;
172const TAG_RESUME: u8 = 5;
173const TAG_WATCH: u8 = 6;
174const TAG_INCOMPATIBLE: u8 = 7;
175const TAG_QUEUED: u8 = 8;
176const TAG_CHAT: u8 = 9;
177const TAG_ROSTER: u8 = 10;
178const TAG_ABANDONED: u8 = 11;
179/// The last of them, which `peek_version` uses to tell one of ours from
180/// stray traffic on the port. Kept here rather than written into that
181/// check, so the line to update sits directly under the line being added.
182const HIGHEST_TAG: u8 = TAG_ABANDONED;
183
184/// How a peer that came to watch is written down in a `Start`: outside
185/// the range of real seats, so it cannot collide with one.
186///
187/// A wire detail and nothing more. Everything above this file says `None`,
188/// and the two are only ever exchanged in the codec below.
189const SPECTATOR_SEAT: u8 = u8::MAX;
190
191/// Everything about a match that every peer has to agree on, or the boards
192/// diverge (or, for `teams`, two peers score the same round differently).
193///
194/// Held as plain numbers rather than the app's enums: this layer is the wire
195/// and knows nothing about menus. The app maps them at the edge.
196#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
197pub struct MatchTerms {
198 /// Seats the AI holds, counting down from the top seat.
199 pub bots: u8,
200 /// AI difficulty index (easy/normal/fierce).
201 pub bot_level: u8,
202 /// Map choice index.
203 pub map: u8,
204 /// Gull pressure index.
205 pub gulls: u8,
206 /// Round length index.
207 pub round: u8,
208 /// How the round is scored, as a team-mode index (free-for-all, pairs,
209 /// trios). A byte rather than a flag since 2026-07-30, when teams stopped
210 /// being only 2v2.
211 pub teams: u8,
212 /// The board's PRNG seed, so every peer builds the same beach. Also
213 /// what tells a fresh `Start` from the stale one a host re-answers a
214 /// stray greeting with: a new seed is a new round.
215 pub seed: u64,
216 /// Best-of-5 rather than a single round. Every peer keeps its own
217 /// tally, and they agree because they are counting the same
218 /// deterministic boards, but only if they all know it is a series.
219 pub series: u8,
220}
221
222impl MatchTerms {
223 const BYTES: usize = 15;
224
225 fn encode(self) -> [u8; Self::BYTES] {
226 let mut out = [0u8; Self::BYTES];
227 out[0] = self.bots;
228 out[1] = self.bot_level;
229 out[2] = self.map;
230 out[3] = self.gulls;
231 out[4] = self.round;
232 out[5] = self.teams;
233 out[6..14].copy_from_slice(&self.seed.to_le_bytes());
234 out[14] = self.series;
235 out
236 }
237
238 fn decode(bytes: &[u8]) -> Option<MatchTerms> {
239 let seed = u64::from_le_bytes(bytes.get(6..14)?.try_into().ok()?);
240 Some(MatchTerms {
241 bots: *bytes.first()?,
242 bot_level: *bytes.get(1)?,
243 map: *bytes.get(2)?,
244 gulls: *bytes.get(3)?,
245 round: *bytes.get(4)?,
246 teams: *bytes.get(5)?,
247 seed,
248 series: *bytes.get(14)?,
249 })
250 }
251}
252
253impl NetMsg {
254 pub fn encode(self) -> Vec<u8> {
255 // Byte 0 tags the message, byte 1 says who wrote it; the payload
256 // starts at byte 2.
257 let mut bytes = match self {
258 NetMsg::Hello { .. } => vec![TAG_HELLO],
259 NetMsg::Watch => vec![TAG_WATCH],
260 NetMsg::Queued { .. } => vec![TAG_QUEUED],
261 NetMsg::Chat { .. } => vec![TAG_CHAT],
262 NetMsg::Roster { .. } => vec![TAG_ROSTER],
263 NetMsg::Abandoned { .. } => vec![TAG_ABANDONED],
264 NetMsg::Input(_) => vec![TAG_INPUT],
265 NetMsg::Hash { .. } => vec![TAG_HASH],
266 NetMsg::Start { .. } => vec![TAG_START],
267 NetMsg::Pause { .. } => vec![TAG_PAUSE],
268 NetMsg::Resume { .. } => vec![TAG_RESUME],
269 NetMsg::Incompatible { version } => return vec![TAG_INCOMPATIBLE, version],
270 };
271 bytes.push(PROTOCOL_VERSION);
272 match self {
273 NetMsg::Watch | NetMsg::Incompatible { .. } => {}
274 NetMsg::Resume { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
275 NetMsg::Queued { ahead } => bytes.push(ahead),
276 NetMsg::Abandoned { seat, frame } => {
277 bytes.push(seat);
278 bytes.extend_from_slice(&frame.to_le_bytes());
279 }
280 NetMsg::Chat { name, text } => {
281 bytes.extend_from_slice(&name);
282 bytes.extend_from_slice(&text);
283 }
284 NetMsg::Roster {
285 seats,
286 names,
287 terms,
288 } => {
289 bytes.push(seats);
290 for name in names {
291 bytes.extend_from_slice(&name);
292 }
293 bytes.extend_from_slice(&terms.encode());
294 }
295 NetMsg::Hello { name } => bytes.extend_from_slice(&name),
296 NetMsg::Input(msg) => bytes.extend_from_slice(&msg.encode()),
297 NetMsg::Hash { frame, hash } => {
298 bytes.extend_from_slice(&frame.to_le_bytes());
299 bytes.extend_from_slice(&hash.to_le_bytes());
300 }
301 NetMsg::Start {
302 seats,
303 seat,
304 terms,
305 names,
306 round,
307 wins,
308 beach,
309 } => {
310 bytes.push(seats);
311 bytes.push(seat.unwrap_or(SPECTATOR_SEAT));
312 bytes.extend_from_slice(&terms.encode());
313 for name in names {
314 bytes.extend_from_slice(&name);
315 }
316 bytes.push(round);
317 bytes.extend_from_slice(&wins);
318 // Length-prefixed and last, so the fixed part above stays
319 // where it was and a beach of any size is one read.
320 let len = u16::try_from(beach.len()).unwrap_or(0);
321 bytes.extend_from_slice(&len.to_le_bytes());
322 bytes.extend_from_slice(&beach[..usize::from(len)]);
323 }
324 NetMsg::Pause { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
325 }
326 bytes
327 }
328
329 /// The protocol version a datagram was written by, whatever else it
330 /// says, and `None` for anything that is not one of our messages at
331 /// all. Byte 1 is frozen across versions so this always answers, and
332 /// the tag is checked first so stray traffic on the port draws no reply.
333 pub fn peek_version(bytes: &[u8]) -> Option<u8> {
334 let tag = *bytes.first()?;
335 (tag <= HIGHEST_TAG)
336 .then(|| bytes.get(1).copied())
337 .flatten()
338 }
339
340 /// Decode a datagram this build can act on. A message from another
341 /// protocol version is refused here rather than half-understood. The
342 /// sole exception is the refusal message itself, which every version
343 /// can read by construction.
344 pub fn decode(bytes: &[u8]) -> Option<NetMsg> {
345 let tag = *bytes.first()?;
346 let version = *bytes.get(1)?;
347 if tag == TAG_INCOMPATIBLE {
348 return Some(NetMsg::Incompatible { version });
349 }
350 if version != PROTOCOL_VERSION {
351 return None;
352 }
353 let body = bytes.get(2..)?;
354 match tag {
355 TAG_HELLO => Some(NetMsg::Hello {
356 name: body.get(..WIRE_NAME)?.try_into().ok()?,
357 }),
358 TAG_WATCH => Some(NetMsg::Watch),
359 TAG_INPUT => {
360 let payload: [u8; INPUT_BYTES] = body.get(..INPUT_BYTES)?.try_into().ok()?;
361 Some(NetMsg::Input(InputMsg::decode(payload)))
362 }
363 TAG_HASH => {
364 let frame = u32::from_le_bytes(body.get(..4)?.try_into().ok()?);
365 let hash = u64::from_le_bytes(body.get(4..12)?.try_into().ok()?);
366 Some(NetMsg::Hash { frame, hash })
367 }
368 TAG_START => {
369 let terms = MatchTerms::decode(body.get(2..)?)?;
370 let mut names = [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS];
371 let table = body.get(2 + MatchTerms::BYTES..)?;
372 for (i, name) in names.iter_mut().enumerate() {
373 *name = table
374 .get(i * WIRE_NAME..(i + 1) * WIRE_NAME)?
375 .try_into()
376 .ok()?;
377 }
378 let (seats, seat) = (*body.first()?, *body.get(1)?);
379 // A table this build cannot sit at is refused outright
380 // rather than squeezed into range. Every per-seat array is
381 // `MAX_PLAYERS` long and the seat number goes on to index
382 // the lockstep's own slots, so a `Start` naming more seats
383 // than there are chairs, or seating us at one that is not
384 // at the table, is not a message to act on. Watching is the
385 // one seat legitimately outside the range.
386 //
387 // The AI holds the top seats, so the humans are the low
388 // `seats - bots` of them, and that is the range the joiner
389 // builds its lockstep from: a `Start` seating us in an AI's
390 // chair would have it play a session it is not a player of,
391 // which the lockstep refuses with a panic. Refused here
392 // instead, with the rest of the unplayable tables.
393 let humans = seats.saturating_sub(terms.bots).max(1);
394 if !(2..=crate::sim::MAX_PLAYERS as u8).contains(&seats)
395 || (seat != SPECTATOR_SEAT && seat >= humans)
396 {
397 return None;
398 }
399 let seat = (seat != SPECTATOR_SEAT).then_some(seat);
400 debug_assert!(seat.is_none_or(|seat| seat < seats));
401 let series_at = 2 + MatchTerms::BYTES + WIRE_NAME * crate::sim::MAX_PLAYERS;
402 let round = *body.get(series_at)?;
403 let wins: [u8; crate::sim::MAX_PLAYERS] = body
404 .get(series_at + 1..series_at + 1 + crate::sim::MAX_PLAYERS)?
405 .try_into()
406 .ok()?;
407 let after = series_at + 1 + crate::sim::MAX_PLAYERS;
408 let beach = match body.get(after..after + 2) {
409 Some(len) => {
410 let len = usize::from(u16::from_le_bytes(len.try_into().ok()?));
411 body.get(after + 2..after + 2 + len)?.to_vec()
412 }
413 None => Vec::new(),
414 };
415 Some(NetMsg::Start {
416 seats,
417 seat,
418 terms,
419 names,
420 round,
421 wins,
422 beach,
423 })
424 }
425 TAG_PAUSE => Some(NetMsg::Pause {
426 frame: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
427 }),
428 TAG_RESUME => Some(NetMsg::Resume {
429 frame: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
430 }),
431 TAG_QUEUED => Some(NetMsg::Queued {
432 ahead: *body.first()?,
433 }),
434 TAG_ABANDONED => {
435 let seat = *body.first()?;
436 let frame = u32::from_le_bytes(body.get(1..5)?.try_into().ok()?);
437 // A seat outside the table is not one anything can be done
438 // about, and acting on it would index off the end.
439 (seat < crate::sim::MAX_PLAYERS as u8).then_some(NetMsg::Abandoned { seat, frame })
440 }
441 TAG_ROSTER => {
442 let table = body.get(1..)?;
443 let mut names = [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS];
444 for (i, name) in names.iter_mut().enumerate() {
445 *name = table
446 .get(i * WIRE_NAME..(i + 1) * WIRE_NAME)?
447 .try_into()
448 .ok()?;
449 }
450 let terms = MatchTerms::decode(table.get(WIRE_NAME * crate::sim::MAX_PLAYERS..)?)?;
451 Some(NetMsg::Roster {
452 seats: *body.first()?,
453 names,
454 terms,
455 })
456 }
457 TAG_CHAT => Some(NetMsg::Chat {
458 name: body.get(..WIRE_NAME)?.try_into().ok()?,
459 text: body
460 .get(WIRE_NAME..WIRE_NAME + WIRE_CHAT)?
461 .try_into()
462 .ok()?,
463 }),
464 _ => None,
465 }
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 /// Every message must encode within the receive buffer, or the kernel
474 /// truncates it on arrival and decode drops it without a trace, which
475 /// is how a 64-byte buffer once swallowed the named `Start` whole.
476 #[test]
477 fn every_message_fits_the_receive_buffer() {
478 let widest = super::wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
479 for msg in [
480 super::NetMsg::Hello { name: widest },
481 super::NetMsg::Chat {
482 name: widest,
483 text: super::wire_chat(&"W".repeat(super::CHAT_CHARS)),
484 },
485 super::NetMsg::Start {
486 seats: 6,
487 seat: Some(5),
488 terms: super::MatchTerms::default(),
489 names: [widest; crate::sim::MAX_PLAYERS],
490 // The widest a Start gets: a full table of longest names
491 // and the largest beach the sender will hand it.
492 round: 0,
493 wins: [0; crate::sim::MAX_PLAYERS],
494 beach: vec![0xAB; super::MAX_BEACH_BYTES],
495 },
496 super::NetMsg::Hash {
497 frame: u32::MAX,
498 hash: u64::MAX,
499 },
500 ] {
501 let len = msg.clone().encode().len();
502 assert!(len <= super::MAX_DATAGRAM, "{len} bytes: {msg:?}");
503 }
504 }
505
506 /// [`MAX_BEACH_BYTES`] is what the sender trusts, so it has to be a
507 /// number this encoder agrees with: the widest possible invitation
508 /// carrying the largest allowed beach must still fit the buffer, with
509 /// room to spare for a field somebody adds to `Start` later.
510 #[test]
511 fn a_start_carrying_the_largest_beach_still_fits() {
512 let widest = super::wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
513 let len = super::NetMsg::Start {
514 seats: 6,
515 seat: Some(5),
516 terms: super::MatchTerms::default(),
517 names: [widest; crate::sim::MAX_PLAYERS],
518 round: 0,
519 wins: [0; crate::sim::MAX_PLAYERS],
520 beach: vec![0xAB; super::MAX_BEACH_BYTES],
521 }
522 .encode()
523 .len();
524 assert!(len <= super::MAX_DATAGRAM, "{len} bytes");
525 let spare = super::MAX_DATAGRAM - len;
526 assert!(spare >= 16, "only {spare} bytes of slack left");
527 }
528
529 #[test]
530 fn decode_rejects_garbage() {
531 assert!(super::NetMsg::decode(&[]).is_none());
532 assert!(super::NetMsg::decode(&[0xFF, 1, 2, 3]).is_none());
533 assert!(super::NetMsg::decode(b"PNCH?").is_none());
534 }
535
536 use crate::sim::{Direction, PlayerAction};
537
538 #[test]
539 fn messages_round_trip() {
540 for msg in [
541 NetMsg::hello("Anna"),
542 NetMsg::hello("Überlang-Name-über-die-Kappe-hinaus"),
543 NetMsg::Watch,
544 NetMsg::Resume { frame: 0 },
545 NetMsg::Resume { frame: 70_000 },
546 NetMsg::Pause { frame: 7 },
547 NetMsg::Input(InputMsg {
548 player: 1,
549 frame: 42,
550 action: PlayerAction::Place {
551 x: 3,
552 y: 8,
553 dir: Direction::Down,
554 },
555 }),
556 // The XL beach is 20 wide: a placement out in column 18 has to
557 // survive the trip, which it did not while a tile was a nibble.
558 NetMsg::Input(InputMsg {
559 player: 5,
560 frame: 4000,
561 action: PlayerAction::Place {
562 x: 18,
563 y: 11,
564 dir: Direction::Left,
565 },
566 }),
567 NetMsg::Hash {
568 frame: 990,
569 hash: 0xDEAD_BEEF_0BAD_F00D,
570 },
571 NetMsg::Start {
572 seats: 6,
573 seat: Some(3),
574 terms: MatchTerms {
575 bots: 2,
576 teams: 1,
577 seed: 0x1234_5678_9ABC_DEF0,
578 ..MatchTerms::default()
579 },
580 names: std::array::from_fn(|i| wire_name(&format!("Seat {i}"))),
581 round: 0,
582 wins: [0; crate::sim::MAX_PLAYERS],
583 beach: b"a handmade beach".to_vec(),
584 },
585 NetMsg::Incompatible { version: 9 },
586 NetMsg::Queued { ahead: 0 },
587 NetMsg::Queued { ahead: 4 },
588 NetMsg::Chat {
589 name: wire_name("Anna"),
590 text: wire_chat("wait for me!"),
591 },
592 NetMsg::Roster {
593 seats: 4,
594 names: std::array::from_fn(|i| wire_name(&format!("P{i}"))),
595 terms: MatchTerms {
596 bots: 1,
597 map: 3,
598 ..MatchTerms::default()
599 },
600 },
601 NetMsg::Abandoned { seat: 0, frame: 0 },
602 NetMsg::Abandoned {
603 seat: crate::sim::MAX_PLAYERS as u8 - 1,
604 frame: 123_456,
605 },
606 ] {
607 assert_eq!(NetMsg::decode(&msg.clone().encode()), Some(msg));
608 }
609 }
610
611 /// A `Start` is the one message that sizes the table, and a joiner acts
612 /// on it before anything else has looked at it: the seat count becomes
613 /// the length every per-seat loop runs to, and the seat number indexes
614 /// the lockstep's slots directly. Both are refused here rather than
615 /// trusted, since a host on a broken build can say anything.
616 #[test]
617 fn a_start_that_seats_more_than_the_table_is_refused() {
618 let good = NetMsg::Start {
619 seats: 6,
620 seat: Some(5),
621 terms: MatchTerms::default(),
622 names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
623 round: 0,
624 wins: [0; crate::sim::MAX_PLAYERS],
625 beach: Vec::new(),
626 };
627 assert_eq!(NetMsg::decode(&good.clone().encode()), Some(good.clone()));
628 // Byte 2 is `seats` and byte 3 is `seat`, just past the tag and
629 // version. Nothing outside the table survives the trip.
630 for (seats, seat) in [(7, 0), (255, 0), (1, 0), (0, 0), (4, 4), (4, 200)] {
631 let mut bytes = good.clone().encode();
632 bytes[2] = seats;
633 bytes[3] = seat;
634 assert_eq!(NetMsg::decode(&bytes), None, "{seats} seats, sat at {seat}");
635 }
636 // Watching is outside the seat range on purpose and stays legal,
637 // and comes back as the absence it means rather than as the number
638 // it travelled in.
639 let mut watching = good.clone().encode();
640 watching[3] = SPECTATOR_SEAT;
641 assert!(matches!(
642 NetMsg::decode(&watching),
643 Some(NetMsg::Start { seat: None, .. })
644 ));
645 // An AI's chair is not one a joiner can be sat in either: the
646 // lockstep it builds carries only the humans, and being seated
647 // outside it was a panic, not a refusal.
648 let with_bots = |seats, bots, seat| {
649 NetMsg::decode(
650 &NetMsg::Start {
651 seats,
652 seat: Some(seat),
653 terms: MatchTerms {
654 bots,
655 ..MatchTerms::default()
656 },
657 names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
658 round: 0,
659 wins: [0; crate::sim::MAX_PLAYERS],
660 beach: Vec::new(),
661 }
662 .encode(),
663 )
664 };
665 assert!(with_bots(6, 4, 1).is_some(), "the last human seat");
666 assert!(with_bots(6, 4, 2).is_none(), "the first AI seat");
667 assert!(with_bots(6, 4, 3).is_none());
668 assert!(with_bots(6, 6, 1).is_none(), "more bots than chairs");
669 assert!(with_bots(6, 6, 0).is_some(), "seat zero always stands");
670 }
671
672 /// The whole point of the version byte: a datagram from another build is
673 /// refused rather than half-understood.
674 #[test]
675 fn another_build_is_refused_and_told_so() {
676 let mut hello = NetMsg::hello("Anna").encode();
677 assert_eq!(hello[1], PROTOCOL_VERSION, "the version byte is byte 1");
678 hello[1] = PROTOCOL_VERSION.wrapping_add(1);
679 assert_eq!(NetMsg::decode(&hello), None, "not ours, so not acted on");
680 assert_eq!(
681 NetMsg::peek_version(&hello),
682 Some(PROTOCOL_VERSION.wrapping_add(1)),
683 "but it can still be identified, which is what gets it answered"
684 );
685 // The refusal itself is exempt from the gate: it is the one message
686 // every version must be able to read, whatever the sender speaks.
687 let refusal = NetMsg::Incompatible { version: 77 }.encode();
688 assert_eq!(refusal.len(), 2, "its layout is frozen at two bytes");
689 assert_eq!(
690 NetMsg::decode(&refusal),
691 Some(NetMsg::Incompatible { version: 77 })
692 );
693 // Stray traffic on the port is not a version clash and draws no reply.
694 assert_eq!(NetMsg::peek_version(&[0xFE, 3]), None);
695 assert_eq!(NetMsg::peek_version(b"PNCH1"), None, "an announcement");
696 }
697}
698
699#[cfg(test)]
700mod wire_fuzz_probe {
701 use super::*;
702
703 /// Every byte string a hostile or broken peer could put on the port,
704 /// through both decoders. Neither may panic, hang, or allocate wildly:
705 /// this is the one surface the LAN can reach without being invited.
706 #[test]
707 fn no_datagram_can_break_a_decoder() {
708 let mut rng = crate::sim::Pcg32::new(0xDEAD_BEEF, 0x1357);
709 // Seed with real messages, so mutations land near valid ones.
710 let mut seeds: Vec<Vec<u8>> = vec![
711 NetMsg::hello("Anna").encode(),
712 NetMsg::Watch.encode(),
713 NetMsg::Queued { ahead: 3 }.encode(),
714 NetMsg::Chat {
715 name: wire_name("Bo"),
716 text: wire_chat("ready?"),
717 }
718 .encode(),
719 NetMsg::Start {
720 seats: 6,
721 seat: Some(2),
722 terms: MatchTerms::default(),
723 names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
724 round: 0,
725 wins: [0; crate::sim::MAX_PLAYERS],
726 beach: Vec::new(),
727 }
728 .encode(),
729 NetMsg::Hash { frame: 7, hash: 9 }.encode(),
730 ANNOUNCE_MAGIC.to_vec(),
731 Vec::new(),
732 ];
733 // And a beacon, built the way the announcer builds one.
734 let mut beacon = ANNOUNCE_MAGIC.to_vec();
735 beacon.extend_from_slice(&49213u16.to_le_bytes());
736 beacon.push(BEACON_RUNNING);
737 beacon.extend_from_slice(&wire_name("Room 3"));
738 beacon.push(4);
739 beacon.push(6);
740 beacon.extend_from_slice(&0x5EA5u64.to_le_bytes());
741 seeds.push(beacon);
742
743 for round in 0..80_000u32 {
744 let mut bytes = seeds[(round as usize) % seeds.len()].clone();
745 for _ in 0..(rng.next_u32() % 6) + 1 {
746 if bytes.is_empty() {
747 bytes.push((rng.next_u32() % 256) as u8);
748 continue;
749 }
750 let at = (rng.next_u32() as usize) % bytes.len();
751 match rng.next_u32() % 4 {
752 0 => bytes[at] = (rng.next_u32() % 256) as u8,
753 1 => drop(bytes.remove(at)),
754 2 => bytes.insert(at, (rng.next_u32() % 256) as u8),
755 _ => bytes.truncate(at),
756 }
757 }
758 // The message decoder, and the version peek that answers strays.
759 if let Some(msg) = NetMsg::decode(&bytes) {
760 // Anything decoded must survive a round trip, or the host
761 // would relay something other than what it was told.
762 assert_eq!(
763 NetMsg::decode(&msg.clone().encode()),
764 Some(msg.clone()),
765 "{bytes:?}"
766 );
767 // And a Start that decoded must be one we can actually seat.
768 if let NetMsg::Start { seats, seat, .. } = msg {
769 assert!((2..=crate::sim::MAX_PLAYERS as u8).contains(&seats));
770 assert!(seat.is_none_or(|seat| seat < seats));
771 }
772 // A seat that decoded is a seat something will index by.
773 if let NetMsg::Abandoned { seat, .. } = msg {
774 assert!(usize::from(seat) < crate::sim::MAX_PLAYERS);
775 }
776 }
777 let _ = NetMsg::peek_version(&bytes);
778 // The beacon decoder shares the port with none of that, but
779 // shares the network with all of it.
780 // Both names it carries: the beach's, and the host's behind it,
781 // which is the one that runs off the end of a short packet.
782 let _ = beacon_name(&bytes, bytes.len(), BEACON_NAME_AT);
783 let _ = beacon_name(&bytes, bytes.len(), BEACON_HOST_AT);
784 }
785 }
786}