Skip to main content

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}