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, or `None` for a
41 /// single round. On the wire the absence is a round number of
42 /// zero, a number no series ever reaches, the way a watcher's seat
43 /// is [`SPECTATOR_SEAT`]; in memory it is an absence.
44 ///
45 /// The host says, because seats move: a peer that leaves between
46 /// rounds frees its chair and everyone behind it moves up one, so
47 /// a tally each peer kept by seat number credited the departed
48 /// player's rounds to whoever moved into the seat. The host holds
49 /// the mapping and re-deals the tally with the chairs; a peer
50 /// admitted from the queue mid-series learns the standings the
51 /// same way, rather than starting a series of its own.
52 standing: Option<SeriesStanding>,
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
145/// Where a series stands as a round begins: its 1-based number, and the
146/// rounds each *seat* has won so far.
147#[derive(Clone, Copy, PartialEq, Eq, Debug)]
148pub struct SeriesStanding {
149 pub round: u8,
150 pub wins: [u8; crate::sim::MAX_PLAYERS],
151}
152
153impl SeriesStanding {
154 /// The standing at the top of a series: round one, nothing won.
155 pub fn opening() -> SeriesStanding {
156 SeriesStanding {
157 round: 1,
158 wins: [0; crate::sim::MAX_PLAYERS],
159 }
160 }
161}
162
163impl NetMsg {
164 /// A greeting carrying `name` in wire form.
165 pub fn hello(name: &str) -> NetMsg {
166 NetMsg::Hello {
167 name: wire_name(name),
168 }
169 }
170
171 /// A line of chat as `who` said it, both halves in wire form. An empty
172 /// `who` is the lobby itself speaking.
173 pub fn chat(who: &str, line: &str) -> NetMsg {
174 NetMsg::Chat {
175 name: wire_name(who),
176 text: wire_chat(line),
177 }
178 }
179}
180
181// Byte 0 of every datagram.
182//
183// Adding a line here is a `PROTOCOL_VERSION` bump, exactly as much as
184// changing the layout of an existing message is, and it is the half that
185// gets forgotten, because nothing stops compiling when you do. Two builds
186// both claiming the same version, one of them sending a tag the other has
187// never heard of, is the silent disagreement that byte exists to prevent:
188// the older one reads it as noise and simply never acts on it.
189// Chat, the roster and the abandonment notice all shipped under version 4
190// before anybody noticed.
191//
192// So a new tag is three lines, not one: the tag, `HIGHEST_TAG` below it,
193// and the version.
194const TAG_HELLO: u8 = 0;
195const TAG_INPUT: u8 = 1;
196const TAG_HASH: u8 = 2;
197const TAG_START: u8 = 3;
198const TAG_PAUSE: u8 = 4;
199const TAG_RESUME: u8 = 5;
200const TAG_WATCH: u8 = 6;
201const TAG_INCOMPATIBLE: u8 = 7;
202const TAG_QUEUED: u8 = 8;
203const TAG_CHAT: u8 = 9;
204const TAG_ROSTER: u8 = 10;
205const TAG_ABANDONED: u8 = 11;
206/// The last of them, which `peek_version` uses to tell one of ours from
207/// stray traffic on the port. Kept here rather than written into that
208/// check, so the line to update sits directly under the line being added.
209const HIGHEST_TAG: u8 = TAG_ABANDONED;
210
211/// How a peer that came to watch is written down in a `Start`: outside
212/// the range of real seats, so it cannot collide with one.
213///
214/// A wire detail and nothing more. Everything above this file says `None`,
215/// and the two are only ever exchanged in the codec below.
216const SPECTATOR_SEAT: u8 = u8::MAX;
217
218/// Everything about a match that every peer has to agree on, or the boards
219/// diverge (or, for `teams`, two peers score the same round differently).
220///
221/// Held as plain numbers rather than the app's enums: this layer is the wire
222/// and knows nothing about menus. The app maps them at the edge.
223#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
224pub struct MatchTerms {
225 /// Seats the AI holds, counting down from the top seat.
226 pub bots: u8,
227 /// AI difficulty index (easy/normal/fierce).
228 pub bot_level: u8,
229 /// Map choice index.
230 pub map: u8,
231 /// Gull pressure index.
232 pub gulls: u8,
233 /// Round length index.
234 pub round: u8,
235 /// How the round is scored, as a team-mode index (free-for-all, pairs,
236 /// trios). A byte rather than a flag since 2026-07-30, when teams stopped
237 /// being only 2v2.
238 pub teams: u8,
239 /// The board's PRNG seed, so every peer builds the same beach. Also
240 /// what tells a fresh `Start` from the stale one a host re-answers a
241 /// stray greeting with: a new seed is a new round.
242 pub seed: u64,
243 /// How long the series runs, as a `SeriesLength` index: one round,
244 /// best of three, best of five. Every peer keeps its own tally, and
245 /// they agree because they are counting the same deterministic boards,
246 /// but only if they all know how many rounds take it.
247 ///
248 /// An index rather than a flag since 2026-08-22, when best-of-three
249 /// joined the dial - which is why the protocol version moved with it:
250 /// a build that read this as a flag would take a 2 for "not a series"
251 /// and stop after one round while the rest of the table played on.
252 pub series: u8,
253}
254
255impl MatchTerms {
256 /// Whether these terms call for a series at all. Index 0 is the dial's
257 /// "single round"; every other index is some best-of. This is the only
258 /// reading of the byte the app should make: comparing it against a
259 /// particular index once left best-of-five launching as a single round
260 /// on every joiner, because `== 1` is best-of-three and nothing else.
261 pub fn is_series(self) -> bool {
262 self.series != 0
263 }
264
265 /// The humans at a table of `seats`: the AI holds the top seats, so
266 /// the humans are the low `seats - bots` of them, and the lockstep is
267 /// built over those alone. Never fewer than one, since seat zero is
268 /// the host's whatever the dials say.
269 pub fn humans(self, seats: u8) -> u8 {
270 seats.saturating_sub(self.bots).max(1)
271 }
272}
273
274impl MatchTerms {
275 const BYTES: usize = 15;
276
277 fn encode(self) -> [u8; Self::BYTES] {
278 let mut out = [0u8; Self::BYTES];
279 out[0] = self.bots;
280 out[1] = self.bot_level;
281 out[2] = self.map;
282 out[3] = self.gulls;
283 out[4] = self.round;
284 out[5] = self.teams;
285 out[6..14].copy_from_slice(&self.seed.to_le_bytes());
286 out[14] = self.series;
287 out
288 }
289
290 fn decode(bytes: &[u8]) -> Option<MatchTerms> {
291 let seed = u64::from_le_bytes(bytes.get(6..14)?.try_into().ok()?);
292 Some(MatchTerms {
293 bots: *bytes.first()?,
294 bot_level: *bytes.get(1)?,
295 map: *bytes.get(2)?,
296 gulls: *bytes.get(3)?,
297 round: *bytes.get(4)?,
298 teams: *bytes.get(5)?,
299 seed,
300 series: *bytes.get(14)?,
301 })
302 }
303}
304
305impl NetMsg {
306 pub fn encode(self) -> Vec<u8> {
307 // Byte 0 tags the message, byte 1 says who wrote it; the payload
308 // starts at byte 2.
309 let mut bytes = match self {
310 NetMsg::Hello { .. } => vec![TAG_HELLO],
311 NetMsg::Watch => vec![TAG_WATCH],
312 NetMsg::Queued { .. } => vec![TAG_QUEUED],
313 NetMsg::Chat { .. } => vec![TAG_CHAT],
314 NetMsg::Roster { .. } => vec![TAG_ROSTER],
315 NetMsg::Abandoned { .. } => vec![TAG_ABANDONED],
316 NetMsg::Input(_) => vec![TAG_INPUT],
317 NetMsg::Hash { .. } => vec![TAG_HASH],
318 NetMsg::Start { .. } => vec![TAG_START],
319 NetMsg::Pause { .. } => vec![TAG_PAUSE],
320 NetMsg::Resume { .. } => vec![TAG_RESUME],
321 NetMsg::Incompatible { version } => return vec![TAG_INCOMPATIBLE, version],
322 };
323 bytes.push(PROTOCOL_VERSION);
324 match self {
325 NetMsg::Watch | NetMsg::Incompatible { .. } => {}
326 NetMsg::Resume { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
327 NetMsg::Queued { ahead } => bytes.push(ahead),
328 NetMsg::Abandoned { seat, frame } => {
329 bytes.push(seat);
330 bytes.extend_from_slice(&frame.to_le_bytes());
331 }
332 NetMsg::Chat { name, text } => {
333 bytes.extend_from_slice(&name);
334 bytes.extend_from_slice(&text);
335 }
336 NetMsg::Roster {
337 seats,
338 names,
339 terms,
340 } => {
341 bytes.push(seats);
342 for name in names {
343 bytes.extend_from_slice(&name);
344 }
345 bytes.extend_from_slice(&terms.encode());
346 }
347 NetMsg::Hello { name } => bytes.extend_from_slice(&name),
348 NetMsg::Input(msg) => bytes.extend_from_slice(&msg.encode()),
349 NetMsg::Hash { frame, hash } => {
350 bytes.extend_from_slice(&frame.to_le_bytes());
351 bytes.extend_from_slice(&hash.to_le_bytes());
352 }
353 NetMsg::Start {
354 seats,
355 seat,
356 terms,
357 names,
358 standing,
359 beach,
360 } => {
361 bytes.push(seats);
362 bytes.push(seat.unwrap_or(SPECTATOR_SEAT));
363 bytes.extend_from_slice(&terms.encode());
364 for name in names {
365 bytes.extend_from_slice(&name);
366 }
367 // Round zero is "no series", as `SPECTATOR_SEAT` is "no
368 // seat": both are numbers the real thing never takes.
369 let SeriesStanding { round, wins } = standing.unwrap_or(SeriesStanding {
370 round: 0,
371 wins: [0; crate::sim::MAX_PLAYERS],
372 });
373 bytes.push(round);
374 bytes.extend_from_slice(&wins);
375 // Length-prefixed and last, so the fixed part above stays
376 // where it was and a beach of any size is one read.
377 let len = u16::try_from(beach.len()).unwrap_or(0);
378 bytes.extend_from_slice(&len.to_le_bytes());
379 bytes.extend_from_slice(&beach[..usize::from(len)]);
380 }
381 NetMsg::Pause { frame } => bytes.extend_from_slice(&frame.to_le_bytes()),
382 }
383 bytes
384 }
385
386 /// The protocol version a datagram was written by, whatever else it
387 /// says, and `None` for anything that is not one of our messages at
388 /// all. Byte 1 is frozen across versions so this always answers, and
389 /// the tag is checked first so stray traffic on the port draws no reply.
390 pub fn peek_version(bytes: &[u8]) -> Option<u8> {
391 let tag = *bytes.first()?;
392 (tag <= HIGHEST_TAG)
393 .then(|| bytes.get(1).copied())
394 .flatten()
395 }
396
397 /// Decode a datagram this build can act on. A message from another
398 /// protocol version is refused here rather than half-understood. The
399 /// sole exception is the refusal message itself, which every version
400 /// can read by construction.
401 pub fn decode(bytes: &[u8]) -> Option<NetMsg> {
402 let tag = *bytes.first()?;
403 let version = *bytes.get(1)?;
404 if tag == TAG_INCOMPATIBLE {
405 return Some(NetMsg::Incompatible { version });
406 }
407 if version != PROTOCOL_VERSION {
408 return None;
409 }
410 let body = bytes.get(2..)?;
411 match tag {
412 TAG_HELLO => Some(NetMsg::Hello {
413 name: body.get(..WIRE_NAME)?.try_into().ok()?,
414 }),
415 TAG_WATCH => Some(NetMsg::Watch),
416 TAG_INPUT => {
417 let payload: [u8; INPUT_BYTES] = body.get(..INPUT_BYTES)?.try_into().ok()?;
418 Some(NetMsg::Input(InputMsg::decode(payload)))
419 }
420 TAG_HASH => {
421 let frame = u32::from_le_bytes(body.get(..4)?.try_into().ok()?);
422 let hash = u64::from_le_bytes(body.get(4..12)?.try_into().ok()?);
423 Some(NetMsg::Hash { frame, hash })
424 }
425 TAG_START => {
426 let terms = MatchTerms::decode(body.get(2..)?)?;
427 let names = read_table(body.get(2 + MatchTerms::BYTES..)?)?;
428 let (seats, seat) = (*body.first()?, *body.get(1)?);
429 // A table this build cannot sit at is refused outright
430 // rather than squeezed into range. Every per-seat array is
431 // `MAX_PLAYERS` long and the seat number goes on to index
432 // the lockstep's own slots, so a `Start` naming more seats
433 // than there are chairs, or seating us at one that is not
434 // at the table, is not a message to act on. Watching is the
435 // one seat legitimately outside the range.
436 //
437 // The AI holds the top seats, so the humans are the low
438 // `seats - bots` of them, and that is the range the joiner
439 // builds its lockstep from: a `Start` seating us in an AI's
440 // chair would have it play a session it is not a player of,
441 // which the lockstep refuses with a panic. Refused here
442 // instead, with the rest of the unplayable tables.
443 let humans = terms.humans(seats);
444 if !(2..=crate::sim::MAX_PLAYERS as u8).contains(&seats)
445 || (seat != SPECTATOR_SEAT && seat >= humans)
446 {
447 return None;
448 }
449 let seat = (seat != SPECTATOR_SEAT).then_some(seat);
450 debug_assert!(seat.is_none_or(|seat| seat < seats));
451 let series_at = 2 + MatchTerms::BYTES + WIRE_NAME * crate::sim::MAX_PLAYERS;
452 let round = *body.get(series_at)?;
453 let wins: [u8; crate::sim::MAX_PLAYERS] = body
454 .get(series_at + 1..series_at + 1 + crate::sim::MAX_PLAYERS)?
455 .try_into()
456 .ok()?;
457 let standing = (round != 0).then_some(SeriesStanding { round, wins });
458 let after = series_at + 1 + crate::sim::MAX_PLAYERS;
459 // Every encoder of this version writes the length, so a
460 // datagram that ends before it is not one of ours.
461 let len = usize::from(u16::from_le_bytes(
462 body.get(after..after + 2)?.try_into().ok()?,
463 ));
464 let beach = body.get(after + 2..after + 2 + len)?.to_vec();
465 Some(NetMsg::Start {
466 seats,
467 seat,
468 terms,
469 names,
470 standing,
471 beach,
472 })
473 }
474 TAG_PAUSE => Some(NetMsg::Pause {
475 frame: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
476 }),
477 TAG_RESUME => Some(NetMsg::Resume {
478 frame: u32::from_le_bytes(body.get(..4)?.try_into().ok()?),
479 }),
480 TAG_QUEUED => Some(NetMsg::Queued {
481 ahead: *body.first()?,
482 }),
483 TAG_ABANDONED => {
484 let seat = *body.first()?;
485 let frame = u32::from_le_bytes(body.get(1..5)?.try_into().ok()?);
486 // A seat outside the table is not one anything can be done
487 // about, and acting on it would index off the end.
488 (seat < crate::sim::MAX_PLAYERS as u8).then_some(NetMsg::Abandoned { seat, frame })
489 }
490 TAG_ROSTER => {
491 let table = body.get(1..)?;
492 let names = read_table(table)?;
493 let terms = MatchTerms::decode(table.get(WIRE_NAME * crate::sim::MAX_PLAYERS..)?)?;
494 Some(NetMsg::Roster {
495 seats: *body.first()?,
496 names,
497 terms,
498 })
499 }
500 TAG_CHAT => Some(NetMsg::Chat {
501 name: body.get(..WIRE_NAME)?.try_into().ok()?,
502 text: body
503 .get(WIRE_NAME..WIRE_NAME + WIRE_CHAT)?
504 .try_into()
505 .ok()?,
506 }),
507 _ => None,
508 }
509 }
510}
511
512/// A full table of names off the front of `bytes`, or `None` if the
513/// datagram ends before the table does. Both messages that carry one
514/// (`Start`, `Roster`) read it this way.
515fn read_table(bytes: &[u8]) -> Option<[WireName; crate::sim::MAX_PLAYERS]> {
516 let mut names = [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS];
517 for (i, name) in names.iter_mut().enumerate() {
518 *name = bytes
519 .get(i * WIRE_NAME..(i + 1) * WIRE_NAME)?
520 .try_into()
521 .ok()?;
522 }
523 Some(names)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 /// Every message must encode within the receive buffer, or the kernel
531 /// truncates it on arrival and decode drops it without a trace, which
532 /// is how a 64-byte buffer once swallowed the named `Start` whole.
533 #[test]
534 fn every_message_fits_the_receive_buffer() {
535 let widest = super::wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
536 for msg in [
537 super::NetMsg::Hello { name: widest },
538 super::NetMsg::Chat {
539 name: widest,
540 text: super::wire_chat(&"W".repeat(super::CHAT_CHARS)),
541 },
542 super::NetMsg::Start {
543 seats: 6,
544 seat: Some(5),
545 terms: super::MatchTerms::default(),
546 names: [widest; crate::sim::MAX_PLAYERS],
547 // The widest a Start gets: a full table of longest names
548 // and the largest beach the sender will hand it.
549 standing: None,
550 beach: vec![0xAB; super::MAX_BEACH_BYTES],
551 },
552 super::NetMsg::Hash {
553 frame: u32::MAX,
554 hash: u64::MAX,
555 },
556 ] {
557 let len = msg.clone().encode().len();
558 assert!(len <= super::MAX_DATAGRAM, "{len} bytes: {msg:?}");
559 }
560 }
561
562 /// [`MAX_BEACH_BYTES`] is what the sender trusts, so it has to be a
563 /// number this encoder agrees with: the widest possible invitation
564 /// carrying the largest allowed beach must still fit the buffer, with
565 /// room to spare for a field somebody adds to `Start` later.
566 #[test]
567 fn a_start_carrying_the_largest_beach_still_fits() {
568 let widest = super::wire_name("WWWWWWWWWWWWWWWWWWWWWWWW");
569 let len = super::NetMsg::Start {
570 seats: 6,
571 seat: Some(5),
572 terms: super::MatchTerms::default(),
573 names: [widest; crate::sim::MAX_PLAYERS],
574 standing: None,
575 beach: vec![0xAB; super::MAX_BEACH_BYTES],
576 }
577 .encode()
578 .len();
579 assert!(len <= super::MAX_DATAGRAM, "{len} bytes");
580 let spare = super::MAX_DATAGRAM - len;
581 assert!(spare >= 16, "only {spare} bytes of slack left");
582 }
583
584 /// The series byte is an *index*, and every index but zero names some
585 /// best-of. Read any narrower and it lies: `== 1` is best-of-three and
586 /// nothing else, which once launched every best-of-five as a single
587 /// round on each joiner while the host went on counting a series.
588 ///
589 /// The whole byte, because a peer on another build picks the number and
590 /// this side has to answer the same way for all of them.
591 #[test]
592 fn every_series_index_but_zero_names_a_series() {
593 let dialled = |series| MatchTerms {
594 series,
595 ..MatchTerms::default()
596 };
597 assert!(!dialled(0).is_series(), "the dial's single round");
598 for index in 1..=u8::MAX {
599 assert!(dialled(index).is_series(), "index {index}");
600 }
601 }
602
603 /// The humans at a table: the range a joiner builds its lockstep over,
604 /// and the range this file's own decoder refuses a seat outside of.
605 ///
606 /// The AI holds the top chairs, so the humans are what is left
607 /// underneath - but never none. Seat zero is the host's whatever the
608 /// dials say, and a `bots` byte from a broken or hostile build must
609 /// come to a floor rather than wrapping the count back up under it.
610 #[test]
611 fn the_humans_are_what_the_ai_leaves_and_never_none() {
612 let with = |bots| MatchTerms {
613 bots,
614 ..MatchTerms::default()
615 };
616 assert_eq!(with(0).humans(6), 6, "no AI, every chair a player's");
617 assert_eq!(with(4).humans(6), 2, "four bots behind two people");
618 assert_eq!(with(5).humans(6), 1, "the host alone");
619 assert_eq!(with(6).humans(6), 1, "and still the host alone");
620 assert_eq!(with(u8::MAX).humans(6), 1, "however wild the byte");
621 assert_eq!(with(0).humans(0), 1, "even a table of nobody seats one");
622 // Whatever it answers is a count the per-seat arrays can hold, which
623 // is what the `Start` decoder leans on when it refuses a seat.
624 for seats in 0..=u8::MAX {
625 for bots in [0, 1, 5, 6, 7, u8::MAX] {
626 let humans = with(bots).humans(seats);
627 assert!(humans >= 1, "{seats} seats, {bots} bots");
628 assert!(humans <= seats.max(1), "{seats} seats, {bots} bots");
629 }
630 }
631 }
632
633 #[test]
634 fn decode_rejects_garbage() {
635 assert!(super::NetMsg::decode(&[]).is_none());
636 assert!(super::NetMsg::decode(&[0xFF, 1, 2, 3]).is_none());
637 assert!(super::NetMsg::decode(b"PNCH?").is_none());
638 }
639
640 use crate::sim::{Direction, PlayerAction};
641
642 #[test]
643 fn messages_round_trip() {
644 for msg in [
645 NetMsg::hello("Anna"),
646 NetMsg::hello("Überlang-Name-über-die-Kappe-hinaus"),
647 NetMsg::Watch,
648 NetMsg::Resume { frame: 0 },
649 NetMsg::Resume { frame: 70_000 },
650 NetMsg::Pause { frame: 7 },
651 NetMsg::Input(InputMsg {
652 player: 1,
653 frame: 42,
654 action: PlayerAction::Place {
655 x: 3,
656 y: 8,
657 dir: Direction::Down,
658 },
659 }),
660 // The XL beach is 20 wide: a placement out in column 18 has to
661 // survive the trip, which it did not while a tile was a nibble.
662 NetMsg::Input(InputMsg {
663 player: 5,
664 frame: 4000,
665 action: PlayerAction::Place {
666 x: 18,
667 y: 11,
668 dir: Direction::Left,
669 },
670 }),
671 NetMsg::Hash {
672 frame: 990,
673 hash: 0xDEAD_BEEF_0BAD_F00D,
674 },
675 NetMsg::Start {
676 seats: 6,
677 seat: Some(3),
678 terms: MatchTerms {
679 bots: 2,
680 teams: 1,
681 seed: 0x1234_5678_9ABC_DEF0,
682 ..MatchTerms::default()
683 },
684 names: std::array::from_fn(|i| wire_name(&format!("Seat {i}"))),
685 standing: None,
686 beach: b"a handmade beach".to_vec(),
687 },
688 // Mid-series: the round and the tally come back as they went,
689 // and as a presence rather than as the zero that means none.
690 NetMsg::Start {
691 seats: 4,
692 seat: Some(2),
693 terms: MatchTerms {
694 series: 2,
695 ..MatchTerms::default()
696 },
697 names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
698 standing: Some(SeriesStanding {
699 round: 3,
700 wins: [1, 0, 1, 0, 0, 0],
701 }),
702 beach: Vec::new(),
703 },
704 NetMsg::Incompatible { version: 9 },
705 NetMsg::Queued { ahead: 0 },
706 NetMsg::Queued { ahead: 4 },
707 NetMsg::Chat {
708 name: wire_name("Anna"),
709 text: wire_chat("wait for me!"),
710 },
711 NetMsg::Roster {
712 seats: 4,
713 names: std::array::from_fn(|i| wire_name(&format!("P{i}"))),
714 terms: MatchTerms {
715 bots: 1,
716 map: 3,
717 ..MatchTerms::default()
718 },
719 },
720 NetMsg::Abandoned { seat: 0, frame: 0 },
721 NetMsg::Abandoned {
722 seat: crate::sim::MAX_PLAYERS as u8 - 1,
723 frame: 123_456,
724 },
725 ] {
726 assert_eq!(NetMsg::decode(&msg.clone().encode()), Some(msg));
727 }
728 }
729
730 /// A `Start` is the one message that sizes the table, and a joiner acts
731 /// on it before anything else has looked at it: the seat count becomes
732 /// the length every per-seat loop runs to, and the seat number indexes
733 /// the lockstep's slots directly. Both are refused here rather than
734 /// trusted, since a host on a broken build can say anything.
735 #[test]
736 fn a_start_that_seats_more_than_the_table_is_refused() {
737 let good = NetMsg::Start {
738 seats: 6,
739 seat: Some(5),
740 terms: MatchTerms::default(),
741 names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
742 standing: None,
743 beach: Vec::new(),
744 };
745 assert_eq!(NetMsg::decode(&good.clone().encode()), Some(good.clone()));
746 // Byte 2 is `seats` and byte 3 is `seat`, just past the tag and
747 // version. Nothing outside the table survives the trip.
748 for (seats, seat) in [(7, 0), (255, 0), (1, 0), (0, 0), (4, 4), (4, 200)] {
749 let mut bytes = good.clone().encode();
750 bytes[2] = seats;
751 bytes[3] = seat;
752 assert_eq!(NetMsg::decode(&bytes), None, "{seats} seats, sat at {seat}");
753 }
754 // Watching is outside the seat range on purpose and stays legal,
755 // and comes back as the absence it means rather than as the number
756 // it travelled in.
757 let mut watching = good.clone().encode();
758 watching[3] = SPECTATOR_SEAT;
759 assert!(matches!(
760 NetMsg::decode(&watching),
761 Some(NetMsg::Start { seat: None, .. })
762 ));
763 // An AI's chair is not one a joiner can be sat in either: the
764 // lockstep it builds carries only the humans, and being seated
765 // outside it was a panic, not a refusal.
766 let with_bots = |seats, bots, seat| {
767 NetMsg::decode(
768 &NetMsg::Start {
769 seats,
770 seat: Some(seat),
771 terms: MatchTerms {
772 bots,
773 ..MatchTerms::default()
774 },
775 names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
776 standing: None,
777 beach: Vec::new(),
778 }
779 .encode(),
780 )
781 };
782 assert!(with_bots(6, 4, 1).is_some(), "the last human seat");
783 assert!(with_bots(6, 4, 2).is_none(), "the first AI seat");
784 assert!(with_bots(6, 4, 3).is_none());
785 assert!(with_bots(6, 6, 1).is_none(), "more bots than chairs");
786 assert!(with_bots(6, 6, 0).is_some(), "seat zero always stands");
787 }
788
789 /// The whole point of the version byte: a datagram from another build is
790 /// refused rather than half-understood.
791 #[test]
792 fn another_build_is_refused_and_told_so() {
793 let mut hello = NetMsg::hello("Anna").encode();
794 assert_eq!(hello[1], PROTOCOL_VERSION, "the version byte is byte 1");
795 hello[1] = PROTOCOL_VERSION.wrapping_add(1);
796 assert_eq!(NetMsg::decode(&hello), None, "not ours, so not acted on");
797 assert_eq!(
798 NetMsg::peek_version(&hello),
799 Some(PROTOCOL_VERSION.wrapping_add(1)),
800 "but it can still be identified, which is what gets it answered"
801 );
802 // The refusal itself is exempt from the gate: it is the one message
803 // every version must be able to read, whatever the sender speaks.
804 let refusal = NetMsg::Incompatible { version: 77 }.encode();
805 assert_eq!(refusal.len(), 2, "its layout is frozen at two bytes");
806 assert_eq!(
807 NetMsg::decode(&refusal),
808 Some(NetMsg::Incompatible { version: 77 })
809 );
810 // Stray traffic on the port is not a version clash and draws no reply.
811 assert_eq!(NetMsg::peek_version(&[0xFE, 3]), None);
812 assert_eq!(NetMsg::peek_version(b"PNCH1"), None, "an announcement");
813 }
814}
815
816#[cfg(test)]
817mod wire_fuzz_probe {
818 use super::*;
819
820 /// Every byte string a hostile or broken peer could put on the port,
821 /// through both decoders. Neither may panic, hang, or allocate wildly:
822 /// this is the one surface the LAN can reach without being invited.
823 #[test]
824 fn no_datagram_can_break_a_decoder() {
825 let mut rng = crate::sim::Pcg32::new(0xDEAD_BEEF, 0x1357);
826 // Seed with real messages, so mutations land near valid ones.
827 let mut seeds: Vec<Vec<u8>> = vec![
828 NetMsg::hello("Anna").encode(),
829 NetMsg::Watch.encode(),
830 NetMsg::Queued { ahead: 3 }.encode(),
831 NetMsg::Chat {
832 name: wire_name("Bo"),
833 text: wire_chat("ready?"),
834 }
835 .encode(),
836 NetMsg::Start {
837 seats: 6,
838 seat: Some(2),
839 terms: MatchTerms::default(),
840 names: [[0u8; WIRE_NAME]; crate::sim::MAX_PLAYERS],
841 standing: None,
842 beach: Vec::new(),
843 }
844 .encode(),
845 NetMsg::Hash { frame: 7, hash: 9 }.encode(),
846 ANNOUNCE_MAGIC.to_vec(),
847 Vec::new(),
848 ];
849 // And a beacon, built the way the announcer builds one.
850 let mut beacon = ANNOUNCE_MAGIC.to_vec();
851 beacon.extend_from_slice(&49213u16.to_le_bytes());
852 beacon.push(BEACON_RUNNING);
853 beacon.extend_from_slice(&wire_name("Room 3"));
854 beacon.push(4);
855 beacon.push(6);
856 beacon.extend_from_slice(&0x5EA5u64.to_le_bytes());
857 seeds.push(beacon);
858
859 for round in 0..80_000u32 {
860 let mut bytes = seeds[(round as usize) % seeds.len()].clone();
861 for _ in 0..(rng.next_u32() % 6) + 1 {
862 if bytes.is_empty() {
863 bytes.push((rng.next_u32() % 256) as u8);
864 continue;
865 }
866 let at = (rng.next_u32() as usize) % bytes.len();
867 match rng.next_u32() % 4 {
868 0 => bytes[at] = (rng.next_u32() % 256) as u8,
869 1 => drop(bytes.remove(at)),
870 2 => bytes.insert(at, (rng.next_u32() % 256) as u8),
871 _ => bytes.truncate(at),
872 }
873 }
874 // The message decoder, and the version peek that answers strays.
875 if let Some(msg) = NetMsg::decode(&bytes) {
876 // Anything decoded must survive a round trip, or the host
877 // would relay something other than what it was told.
878 assert_eq!(
879 NetMsg::decode(&msg.clone().encode()),
880 Some(msg.clone()),
881 "{bytes:?}"
882 );
883 // And a Start that decoded must be one we can actually seat.
884 if let NetMsg::Start { seats, seat, .. } = msg {
885 assert!((2..=crate::sim::MAX_PLAYERS as u8).contains(&seats));
886 assert!(seat.is_none_or(|seat| seat < seats));
887 }
888 // A seat that decoded is a seat something will index by.
889 if let NetMsg::Abandoned { seat, .. } = msg {
890 assert!(usize::from(seat) < crate::sim::MAX_PLAYERS);
891 }
892 }
893 let _ = NetMsg::peek_version(&bytes);
894 // The beacon decoder shares the port with none of that, but
895 // shares the network with all of it.
896 // Both names it carries: the beach's, and the host's behind it,
897 // which is the one that runs off the end of a short packet.
898 let _ = beacon_name(&bytes, bytes.len(), BEACON_NAME_AT);
899 let _ = beacon_name(&bytes, bytes.len(), BEACON_HOST_AT);
900 }
901 }
902}