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: 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}