diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 5767e4d..f3b6bf6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -43,6 +43,7 @@ jobs: - share_audio_datagram - share_reassembler - share_control_line + - share_pad_datagram runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CONTEXT.md b/CONTEXT.md index 3c2ca1d..8b96d75 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -287,6 +287,14 @@ _Avoid_: Stream tunnel, call The helper program on a player's PC that, for a host, turns the game's pictures and sound into the stream and, for a guest, shows it and reads the guest's pad. It is downloaded by the launcher the first time it is needed. _Avoid_: Encoder, plugin, viewer (that is only the guest's side) +**Frame ring**: +The block of shared memory on the host's PC that the share worker makes and the game's engine fills with the pictures it presents and the sound it plays. One writer, one reader, no locks; the engine never waits for the worker. It is named at random for each share and only the user who runs the game can open it. +_Avoid_: Capture buffer, shared texture + +**Pad state**: +One complete reading of the guest pad: fifteen buttons, two sticks and two triggers, and nothing else. The guest's viewer sends them, the host's net service checks and rate-limits them, and the engine plays them as a pad. +_Avoid_: Input event, key press + **Clip**: The last seconds of a player's running game, kept in memory by the share worker while Clips is on and saved as an MP4 file when the player asks. It stays on their PC. Not a share screen (that goes to the party) and not a recording (which would run from a start to a stop). _Avoid_: Recording, capture, replay diff --git a/Cargo.lock b/Cargo.lock index d7a3431..4072719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,7 @@ dependencies = [ "anyplay-bridge-protocol", "anyplay-control", "anyplay-discord", + "anyplay-fake-game", "anyplay-proof", "anyplay-share", "anyplay-share-worker", @@ -275,6 +276,7 @@ dependencies = [ "dispatch2", "iroh", "iroh-relay", + "libc", "minifb", "mp4", "objc2", diff --git a/crates/bridge-protocol/src/lib.rs b/crates/bridge-protocol/src/lib.rs index 406279e..c0330de 100644 --- a/crates/bridge-protocol/src/lib.rs +++ b/crates/bridge-protocol/src/lib.rs @@ -44,6 +44,12 @@ impl Kind { pub const UNBIND: Kind = Kind(0x0306); pub const INTENT: Kind = Kind(0x0401); pub const INVITATION_DIALOG: Kind = Kind(0x0402); + pub const SHARE_OPEN: Kind = Kind(0x0501); + pub const SHARE_CLOSE: Kind = Kind(0x0502); + pub const SHARE_MODE: Kind = Kind(0x0503); + pub const SHARE_PAD: Kind = Kind(0x0504); + pub const OVERRIDE: Kind = Kind(0x0505); + pub const SHARE_STATUS: Kind = Kind(0x0506); /// The kind of the reply to a request of this kind. pub const fn reply(self) -> Kind { @@ -216,6 +222,13 @@ pub enum Message { Intent(Intent), InvitationDialog(InvitationDialog), InvitationDialogReply { result: DialogResult, sent: u16 }, + ShareOpen(ShareOpen), + ShareOpened { result: ShareResult }, + ShareClose, + ShareMode { mode: ShareMode }, + SharePad(SharePad), + Override { action: OverrideAction }, + ShareStatus(ShareStatus), } impl Message { @@ -244,6 +257,13 @@ impl Message { Message::Intent(_) => Kind::INTENT, Message::InvitationDialog(_) => Kind::INVITATION_DIALOG, Message::InvitationDialogReply { .. } => Kind::INVITATION_DIALOG.reply(), + Message::ShareOpen(_) => Kind::SHARE_OPEN, + Message::ShareOpened { .. } => Kind::SHARE_OPEN.reply(), + Message::ShareClose => Kind::SHARE_CLOSE, + Message::ShareMode { .. } => Kind::SHARE_MODE, + Message::SharePad(_) => Kind::SHARE_PAD, + Message::Override { .. } => Kind::OVERRIDE, + Message::ShareStatus(_) => Kind::SHARE_STATUS, } } @@ -352,6 +372,34 @@ impl Message { w.u8(*result as u8); w.u16(*sent); } + Message::ShareOpen(m) => { + w.str(&m.ring)?; + w.u32(m.ring_bytes); + w.u32(m.width); + w.u32(m.height); + w.u32(m.max_fps); + w.u8(m.mode as u8); + } + Message::ShareOpened { result } => w.u8(*result as u8), + Message::ShareClose => {} + Message::ShareMode { mode } => w.u8(*mode as u8), + Message::SharePad(m) => { + w.u8(m.slot); + w.u16(m.sequence); + w.u32(m.buttons); + w.u8(m.lx); + w.u8(m.ly); + w.u8(m.rx); + w.u8(m.ry); + w.u8(m.l2); + w.u8(m.r2); + } + Message::Override { action } => w.u8(*action as u8), + Message::ShareStatus(m) => { + w.u32(m.frames); + w.u32(m.dropped); + w.u32(m.audio_underruns); + } } Ok(w.into_inner()) } @@ -470,6 +518,40 @@ impl Message { result: DialogResult::from(r.u8()?), sent: r.u16()?, }, + Kind::SHARE_OPEN => Message::ShareOpen(ShareOpen { + ring: r.str()?, + ring_bytes: r.u32()?, + width: r.u32()?, + height: r.u32()?, + max_fps: r.u32()?, + mode: ShareMode::from(r.u8()?), + }), + k if k == Kind::SHARE_OPEN.reply() => Message::ShareOpened { + result: ShareResult::from(r.u8()?), + }, + Kind::SHARE_CLOSE => Message::ShareClose, + Kind::SHARE_MODE => Message::ShareMode { + mode: ShareMode::from(r.u8()?), + }, + Kind::SHARE_PAD => Message::SharePad(SharePad { + slot: r.u8()?, + sequence: r.u16()?, + buttons: r.u32()?, + lx: r.u8()?, + ly: r.u8()?, + rx: r.u8()?, + ry: r.u8()?, + l2: r.u8()?, + r2: r.u8()?, + }), + Kind::OVERRIDE => Message::Override { + action: OverrideAction::from(r.u8()?), + }, + Kind::SHARE_STATUS => Message::ShareStatus(ShareStatus { + frames: r.u32()?, + dropped: r.u32()?, + audio_underruns: r.u32()?, + }), _ => return Ok(None), }; Ok(Some(message)) @@ -615,6 +697,60 @@ pub struct InvitationDialog { pub max: u16, } +/// The service asks the engine to start copying the game's pictures into a frame ring that the +/// share worker created (`spec/share-play.md#frame-ring`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShareOpen { + /// The ring's name: a named file mapping on Windows, a `shm_open` object on Linux. + pub ring: String, + /// The size of the mapping in bytes, so that the engine can refuse one that is too small. + pub ring_bytes: u32, + pub width: u32, + pub height: u32, + pub max_fps: u32, + pub mode: ShareMode, +} + +/// One pad state of the guest, already checked by the service. `slot` is 0 for pad 1 and 1 for +/// pad 2; the bits of `buttons` are the names of `spec/share-play.md#pad-state`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SharePad { + pub slot: u8, + pub sequence: u16, + pub buttons: u32, + pub lx: u8, + pub ly: u8, + pub rx: u8, + pub ry: u8, + pub l2: u8, + pub r2: u8, +} + +impl SharePad { + /// No buttons, sticks in the middle, triggers up: what the game sees when the guest is gone. + pub const fn neutral(slot: u8, sequence: u16) -> SharePad { + SharePad { + slot, + sequence, + buttons: 0, + lx: 128, + ly: 128, + rx: 128, + ry: 128, + l2: 0, + r2: 0, + } + } +} + +/// What the engine reports once a second while the ring is in use. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShareStatus { + pub frames: u32, + pub dropped: u32, + pub audio_underruns: u32, +} + macro_rules! byte_enum { ($(#[$meta:meta])* $name:ident { $($variant:ident = $value:literal),+ $(,)? }) => { $(#[$meta])* @@ -643,6 +779,18 @@ byte_enum!(PeerState { Connecting = 1, Connected = 2, Disconnected = 3, Failed = byte_enum!(Transport { Datagram = 1, Stream = 2 }); byte_enum!(BindResult { Bound = 0, InUse = 1 }); byte_enum!(DialogResult { Sent = 0, Cancelled = 1 }); +byte_enum!( + /// What the guest may do: watch only, play as the host's pad, or play a second pad. + ShareMode { Watch = 0, HandOver = 1, Together = 2 } +); +byte_enum!( + /// The engine's answer to a share open. + ShareResult { Copying = 0, RingUnavailable = 1, Running = 2, NotPresenting = 3 } +); +byte_enum!( + /// What the host did on their own pad or keyboard: took control back, or asked to stop. + OverrideAction { TookControl = 1, Stop = 2 } +); /// The header in front of every game datagram and at the start of every game stream. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/bridge-protocol/src/tests.rs b/crates/bridge-protocol/src/tests.rs index 49e12fe..e5b2cc7 100644 --- a/crates/bridge-protocol/src/tests.rs +++ b/crates/bridge-protocol/src/tests.rs @@ -254,9 +254,176 @@ fn samples() -> Vec { result: DialogResult::Sent, sent: 2, }, + Message::ShareOpen(ShareOpen { + ring: "anyplay-ring-9f3a".into(), + ring_bytes: 1_920 * 1_080 * 4 * 3 + 1_000, + width: 1_920, + height: 1_080, + max_fps: 60, + mode: ShareMode::Together, + }), + Message::ShareOpened { + result: ShareResult::NotPresenting, + }, + Message::ShareClose, + Message::ShareMode { + mode: ShareMode::HandOver, + }, + Message::SharePad(SharePad { + slot: 1, + sequence: 65_535, + buttons: 0x0000_7FFF, + lx: 0, + ly: 255, + rx: 128, + ry: 1, + l2: 200, + r2: 7, + }), + Message::Override { + action: OverrideAction::Stop, + }, + Message::ShareStatus(ShareStatus { + frames: 3_600, + dropped: 12, + audio_underruns: 1, + }), ] } +#[test] +fn share_play_kinds_are_the_ones_in_the_spec() { + let kinds: Vec = [ + Kind::SHARE_OPEN, + Kind::SHARE_OPEN.reply(), + Kind::SHARE_CLOSE, + Kind::SHARE_MODE, + Kind::SHARE_PAD, + Kind::OVERRIDE, + Kind::SHARE_STATUS, + ] + .iter() + .map(|k| k.0) + .collect(); + assert_eq!( + kinds, + [0x0501, 0x8501, 0x0502, 0x0503, 0x0504, 0x0505, 0x0506] + ); +} + +#[test] +fn share_play_payloads_have_the_layout_of_the_spec() { + let open = Message::ShareOpen(ShareOpen { + ring: "r1".into(), + ring_bytes: 0x0102_0304, + width: 1_280, + height: 720, + max_fps: 60, + mode: ShareMode::HandOver, + }); + assert_eq!( + open.payload().unwrap(), + [ + 2, 0, b'r', b'1', // ring: str + 4, 3, 2, 1, // ring_bytes + 0, 5, 0, 0, // width + 0xD0, 2, 0, 0, // height + 60, 0, 0, 0, // max_fps + 1, // mode + ] + ); + assert_eq!( + Message::ShareOpened { + result: ShareResult::Running + } + .payload() + .unwrap(), + [2] + ); + assert!(Message::ShareClose.payload().unwrap().is_empty()); + assert_eq!( + Message::ShareMode { + mode: ShareMode::Together + } + .payload() + .unwrap(), + [2] + ); + let pad = Message::SharePad(SharePad { + slot: 1, + sequence: 0x0201, + buttons: 0x0000_4010, + lx: 1, + ly: 2, + rx: 3, + ry: 4, + l2: 5, + r2: 6, + }); + assert_eq!( + pad.payload().unwrap(), + [1, 1, 2, 0x10, 0x40, 0, 0, 1, 2, 3, 4, 5, 6] + ); + assert_eq!( + Message::Override { + action: OverrideAction::TookControl + } + .payload() + .unwrap(), + [1] + ); + assert_eq!( + Message::ShareStatus(ShareStatus { + frames: 1, + dropped: 2, + audio_underruns: 3 + }) + .payload() + .unwrap(), + [1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0] + ); +} + +#[test] +fn a_neutral_pad_is_nothing_pressed_and_sticks_in_the_middle() { + assert_eq!( + SharePad::neutral(1, 9), + SharePad { + slot: 1, + sequence: 9, + buttons: 0, + lx: 128, + ly: 128, + rx: 128, + ry: 128, + l2: 0, + r2: 0, + } + ); +} + +#[test] +fn unknown_share_values_are_tolerated() { + assert_eq!( + Message::parse(Kind::SHARE_MODE, &[9]), + Ok(Some(Message::ShareMode { + mode: ShareMode::Unknown + })) + ); + assert_eq!( + Message::parse(Kind::SHARE_OPEN.reply(), &[77]), + Ok(Some(Message::ShareOpened { + result: ShareResult::Unknown + })) + ); + assert_eq!( + Message::parse(Kind::OVERRIDE, &[0]), + Ok(Some(Message::Override { + action: OverrideAction::Unknown + })) + ); +} + #[test] fn every_message_survives_the_round_trip() { for message in samples() { diff --git a/crates/fake-game/src/lib.rs b/crates/fake-game/src/lib.rs index f62d4be..f70df88 100644 --- a/crates/fake-game/src/lib.rs +++ b/crates/fake-game/src/lib.rs @@ -9,7 +9,8 @@ use std::time::Duration; use anyplay_bridge_protocol::{ Activate, ActivateResult, Bind, BindResult, Decoder, Frame, Hello, Kind, Message, SessionKind, - State, TrafficHeader, Transport, WebReply, WebRequest, Welcome, + ShareMode, ShareOpen, SharePad, ShareResult, State, TrafficHeader, Transport, WebReply, + WebRequest, Welcome, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpStream, UdpSocket}; @@ -20,6 +21,92 @@ const PATIENCE: Duration = Duration::from_secs(30); type Waiters = Arc>>>; +/// What the engine's share play does when the net service speaks to it, with the rules the +/// bridge in AnyPS5 follows (`spec/share-play.md`, Bridge protocol additions): it takes a pad +/// only in a mode that has a pad, on the slot of that mode, with no reserved button bit, and +/// the guest's pad is neutral whenever the mode or the share ends. +#[derive(Clone, Debug, Default)] +pub struct ShareEngine { + /// The answer to the next `share_open`; `None` is "copying". + pub refuse_with: Option, + /// The last `share_open` that was answered with "copying". + pub opened: Option, + /// The mode the net service last set; `None` before an open. + pub mode: Option, + /// A `share_close` came after the open. + pub closed: bool, + /// Every pad state the engine took, in order. + pub taken: Vec, + /// Pad states the engine refused (wrong mode, wrong slot, reserved buttons). + pub refused: u32, + /// What the game sees on pad 1 and pad 2 from the guest now; `None` when no guest pad is + /// connected. + pub now: [Option; 2], + /// Frames the net service sent that this engine does not know. + pub unknown: u32, +} + +impl ShareEngine { + fn slot_of(mode: ShareMode) -> Option { + match mode { + ShareMode::HandOver => Some(0), + ShareMode::Together => Some(1), + ShareMode::Watch | ShareMode::Unknown => None, + } + } + + fn disconnect(&mut self) { + self.now = [None, None]; + } + + fn apply(&mut self, message: &Message) -> Option { + match message { + Message::ShareOpen(open) => { + if self.opened.is_some() && !self.closed { + return Some(ShareResult::Running); + } + if let Some(result) = self.refuse_with { + return Some(result); + } + self.opened = Some(open.clone()); + self.closed = false; + self.mode = Some(open.mode); + self.taken.clear(); + self.refused = 0; + self.disconnect(); + Some(ShareResult::Copying) + } + Message::ShareClose => { + self.closed = true; + self.disconnect(); + None + } + Message::ShareMode { mode } => { + self.mode = Some(*mode); + self.disconnect(); + None + } + Message::SharePad(pad) => { + let allowed = self.opened.is_some() + && !self.closed + && self.mode.and_then(Self::slot_of) == Some(pad.slot) + && pad.buttons & !0x7FFF == 0; + if allowed { + self.taken.push(*pad); + self.now[usize::from(pad.slot)] = Some(*pad); + } else { + self.refused += 1; + } + None + } + _ => { + self.unknown += 1; + None + } + } + } +} + /// A connected bridge. pub struct Bridge { writer: mpsc::UnboundedSender, @@ -28,6 +115,17 @@ pub struct Bridge { welcome: Welcome, state: watch::Receiver, events: tokio::sync::Mutex>, + share: Arc>, + tasks: Vec>, +} + +impl Drop for Bridge { + /// The game exits: both halves of the connection go, and the net service sees it close. + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + } } impl Bridge { @@ -75,7 +173,7 @@ impl Bridge { }; let (writer, mut outgoing) = mpsc::unbounded_channel::(); - tokio::spawn(async move { + let writing = tokio::spawn(async move { let mut bytes = Vec::new(); while let Some(frame) = outgoing.recv().await { bytes.clear(); @@ -87,7 +185,9 @@ impl Bridge { let waiters: Waiters = Arc::default(); let (state_tx, state) = watch::channel(State::signed_out()); let (event_tx, events) = mpsc::unbounded_channel(); - { + let share: Arc> = Arc::default(); + let reading = { + let share = share.clone(); let waiters = waiters.clone(); let writer = writer.clone(); tokio::spawn(async move { @@ -118,6 +218,23 @@ impl Bridge { Message::State(new) => { let _ = state_tx.send(new); } + share_message @ (Message::ShareOpen(_) + | Message::ShareClose + | Message::ShareMode { .. } + | Message::SharePad(_)) => { + let answer = share + .lock() + .unwrap_or_else(|e| e.into_inner()) + .apply(&share_message); + if let (Some(result), true) = (answer, frame.request != 0) + && let Ok(reply) = Frame::numbered( + &Message::ShareOpened { result }, + frame.request, + ) + { + let _ = writer.send(reply); + } + } other => { let _ = event_tx.send(other); } @@ -131,8 +248,8 @@ impl Bridge { Ok(n) => decoder.push(&buf[..n]), } } - }); - } + }) + }; Ok(Bridge { writer, waiters, @@ -140,9 +257,24 @@ impl Bridge { welcome, state, events: tokio::sync::Mutex::new(events), + share, + tasks: vec![writing, reading], }) } + /// What the engine's share play has seen and done so far. + pub fn share_engine(&self) -> ShareEngine { + self.share.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + + /// Makes the engine answer the next `share_open` with `result` instead of "copying". + pub fn refuse_share_open(&self, result: ShareResult) { + self.share + .lock() + .unwrap_or_else(|e| e.into_inner()) + .refuse_with = Some(result); + } + /// Connects with the variables the launcher gives a game. pub async fn from_env() -> Result { let endpoint = diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml index 58c0cce..ac4a281 100644 --- a/crates/net/Cargo.toml +++ b/crates/net/Cargo.toml @@ -51,6 +51,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] +anyplay-fake-game = { path = "../fake-game" } anyplay-share-worker = { path = "../share-worker" } axum = { version = "0.8", features = ["ws"] } iroh-relay = { version = "1.4.1", features = ["server"] } diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 107847f..d013614 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -16,6 +16,7 @@ pub mod events; pub mod observe; pub mod screen; pub mod service; +pub mod shareplay; pub mod traffic; pub mod tunnel; mod typing; diff --git a/crates/net/src/screen.rs b/crates/net/src/screen.rs index c601d5d..876c75e 100644 --- a/crates/net/src/screen.rs +++ b/crates/net/src/screen.rs @@ -1031,7 +1031,11 @@ impl Task { }); self.stop_sharing("ended"); } - FromWorker::Report(_) | FromWorker::Key(_) => {} + // Share screen has no pad and no ring: nothing of the kind is acted on. + FromWorker::Report(_) + | FromWorker::Key(_) + | FromWorker::Pad(_) + | FromWorker::RingReady(_) => {} } } WorkerRole::Viewer => { @@ -1065,7 +1069,9 @@ impl Task { tracing::info!("screen: viewer worker ended: {reason}"); self.leave(); } - FromWorker::Datagram(_) => {} + FromWorker::Datagram(_) | FromWorker::RingReady(_) => {} + // Share screen is watch only (ADR 0023): a pad state is dropped here. + FromWorker::Pad(_) => {} } } } diff --git a/crates/net/src/service.rs b/crates/net/src/service.rs index a4780d2..48144ef 100644 --- a/crates/net/src/service.rs +++ b/crates/net/src/service.rs @@ -4093,7 +4093,17 @@ impl Service { | Message::PeerInfoReply(_) | Message::Bound { .. } | Message::Intent(_) - | Message::InvitationDialogReply { .. } => { + | Message::InvitationDialogReply { .. } + // The share kinds travel from the service to the game. Its host side is + // `shareplay::ShareHost`; a game that sends one of them to a service that holds no + // share is answered `unsupported` (a request) or ignored (an event). + | Message::ShareOpen(_) + | Message::ShareOpened { .. } + | Message::ShareClose + | Message::ShareMode { .. } + | Message::SharePad(_) + | Message::Override { .. } + | Message::ShareStatus(_) => { if request != 0 { self.bridge_reply(connection, request, Message::Unsupported); } diff --git a/crates/net/src/shareplay.rs b/crates/net/src/shareplay.rs new file mode 100644 index 0000000..e81d816 --- /dev/null +++ b/crates/net/src/shareplay.rs @@ -0,0 +1,656 @@ +//! The host's side of share play towards the game: the frame ring handed to the engine, the +//! mode, and the guest's pad states on their way in (`spec/share-play.md`, Bridge protocol +//! additions and Input). +//! +//! [`ShareHost`] is a state machine and nothing else: it takes what happens (a ring that the +//! share worker made, a bridge frame, a datagram of the share connection, the clock) and +//! answers with [`Step`]s, the frames to send to the bridge and what the rest of the service +//! has to do. It owns no socket and no clock, so the rules are tested without either. The share +//! connection and the cloud's `Share` record sit above it and are not part of it. +//! +//! What it holds to: +//! +//! * Pad states reach the game only while the ring is open and the mode is `hand_over` or +//! `together`, and only through [`PadGate`]: exact parsing, 300 states a second, the order of +//! the sequence, 20 invalid datagrams in 5 seconds end the connection. +//! * A state is a pad and nothing else; there is no message here that carries a key, a file or +//! a pointer. +//! * Whenever the guest's pad goes away (the mode changes, the share closes, the guest falls +//! silent for 250 ms, the bridge is lost) the game has been given a neutral state first. + +use anyplay_bridge_protocol::{ + Frame, Message, OverrideAction, ShareMode, ShareOpen, SharePad, ShareResult, ShareStatus, +}; +use anyplay_share::link::RingReady; +use anyplay_share::pad::{PadGate, PadMode, PadState}; + +/// The close code of the share connection for too many invalid pad datagrams. +pub const CLOSE_TOO_MANY_INVALID: u16 = 7; + +/// What the service has to do after an input. +#[derive(Debug, PartialEq, Eq)] +pub enum Step { + /// A frame for the bridge. + Bridge(Frame), + /// Something the rest of the service acts on. + Event(Event), +} + +/// What the rest of the service learns. +#[derive(Debug, PartialEq, Eq)] +pub enum Event { + /// The engine copies the game's pictures into the ring. + Opened, + /// The engine refused the ring. The share cannot start. + OpenFailed(ShareResult), + /// The mode is now this one (the host asked for it, or took control back). + Mode(ShareMode), + /// The host took control back on their own pad: the mode is now `watch`. The guest, the + /// launcher and the cloud are told. + TookControl, + /// The host asked to stop (the stop key, or the chord while only watching). + StopRequested, + /// The engine's count of frames, dropped frames and sound underruns, once a second. + Status(ShareStatus), + /// Close the share connection with this code. + CloseGuest(u16), + /// The bridge went away: the share is over. + BridgeLost, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + /// `share_open` is sent, with this request number, and not answered. + Opening(u32), + Open, + Closed, +} + +/// One share on the host's PC, from the ring to the game's pad. +#[derive(Debug)] +pub struct ShareHost { + gate: PadGate, + phase: Phase, + mode: ShareMode, + /// The sequence of the newest state sent to the game, for the neutral states after it. + sequence: u16, +} + +fn pad_mode(mode: ShareMode) -> PadMode { + match mode { + ShareMode::HandOver => PadMode::HandOver, + ShareMode::Together => PadMode::Together, + ShareMode::Watch | ShareMode::Unknown => PadMode::Watch, + } +} + +fn event(message: Message) -> Step { + Step::Bridge(Frame::event(&message).expect("a message of this size can be framed")) +} + +impl ShareHost { + /// Starts a share: the worker's ring is ready, and the game is asked to fill it. The + /// first steps are the `share_open` request. + pub fn open(now_us: u64, ring: &RingReady, mode: ShareMode) -> (ShareHost, Vec) { + let mode = if mode == ShareMode::Unknown { + ShareMode::Watch + } else { + mode + }; + let mut gate = PadGate::new(now_us); + gate.set_mode(pad_mode(mode)); + let request = 1; + let open = Message::ShareOpen(ShareOpen { + ring: ring.name.clone(), + ring_bytes: ring.bytes, + width: ring.width, + height: ring.height, + max_fps: ring.max_fps, + mode, + }); + let frame = Frame::numbered(&open, request).expect("a share open can be framed"); + ( + ShareHost { + gate, + phase: Phase::Opening(request), + mode, + sequence: 0, + }, + vec![Step::Bridge(frame)], + ) + } + + pub fn mode(&self) -> ShareMode { + self.mode + } + + /// True while the engine copies pictures and a pad may be played. + pub fn is_open(&self) -> bool { + self.phase == Phase::Open + } + + /// True once the share is over: closed by the host, refused, or the bridge was lost. + pub fn is_closed(&self) -> bool { + self.phase == Phase::Closed + } + + /// The slot the guest plays on in the current mode. + fn slot(&self) -> Option { + self.gate.mode().slot() + } + + fn neutral(&self) -> Option { + let slot = self.slot()?; + (self.phase != Phase::Closed) + .then(|| event(Message::SharePad(SharePad::neutral(slot, self.sequence)))) + } + + /// The host changes the mode. The guest's pad is neutral on the old slot before it goes. + pub fn set_mode(&mut self, mode: ShareMode) -> Vec { + if self.phase == Phase::Closed || mode == ShareMode::Unknown || mode == self.mode { + return Vec::new(); + } + let mut steps = Vec::new(); + steps.extend(self.neutral()); + self.mode = mode; + self.gate.set_mode(pad_mode(mode)); + steps.push(event(Message::ShareMode { mode })); + steps.push(Step::Event(Event::Mode(mode))); + steps + } + + /// The share ends from the host's side: neutral, then the game stops copying. + pub fn close(&mut self) -> Vec { + if self.phase == Phase::Closed { + return Vec::new(); + } + let mut steps = Vec::new(); + steps.extend(self.neutral()); + steps.push(event(Message::ShareClose)); + self.phase = Phase::Closed; + steps + } + + /// The bridge's connection broke. The engine has already neutralised the guest's pad and + /// stopped copying; the share is over. + pub fn bridge_lost(&mut self) -> Vec { + if self.phase == Phase::Closed { + return Vec::new(); + } + self.phase = Phase::Closed; + vec![Step::Event(Event::BridgeLost)] + } + + /// A frame from the bridge: the answer to `share_open`, the host's override, the status. + pub fn on_bridge(&mut self, frame: &Frame) -> Vec { + let Ok(Some(message)) = frame.message() else { + return Vec::new(); + }; + match message { + Message::ShareOpened { result } => { + if self.phase != Phase::Opening(frame.request) { + return Vec::new(); + } + if result == ShareResult::Copying { + self.phase = Phase::Open; + vec![Step::Event(Event::Opened)] + } else { + self.phase = Phase::Closed; + vec![Step::Event(Event::OpenFailed(result))] + } + } + Message::Override { action } if self.phase == Phase::Open => match action { + OverrideAction::TookControl => { + let mut steps = Vec::new(); + if self.mode != ShareMode::Watch { + // The engine has already dropped the guest's pad; this keeps both + // sides of the bridge in the same mode. + steps.extend(self.neutral()); + self.mode = ShareMode::Watch; + self.gate.set_mode(PadMode::Watch); + steps.push(event(Message::ShareMode { + mode: ShareMode::Watch, + })); + } + steps.push(Step::Event(Event::TookControl)); + steps + } + OverrideAction::Stop => vec![Step::Event(Event::StopRequested)], + OverrideAction::Unknown => Vec::new(), + }, + Message::ShareStatus(status) if self.phase == Phase::Open => { + vec![Step::Event(Event::Status(status))] + } + _ => Vec::new(), + } + } + + /// A datagram that came on the share connection and is a pad datagram (the caller has + /// looked at its type byte). + pub fn on_pad_datagram(&mut self, now_us: u64, bytes: &[u8]) -> Vec { + if self.phase != Phase::Open { + return Vec::new(); + } + let verdict = self.gate.on_datagram(now_us, bytes); + let mut steps = Vec::new(); + if let Some(slot) = self.slot() { + for (sequence, state) in verdict.apply { + self.sequence = sequence; + steps.push(event(Message::SharePad(share_pad(slot, sequence, state)))); + } + } + if verdict.close { + steps.push(Step::Event(Event::CloseGuest(CLOSE_TOO_MANY_INVALID))); + } + steps + } + + /// Call at least every 50 ms. After 250 ms without a valid pad datagram the game gets a + /// neutral state, once. + pub fn tick(&mut self, now_us: u64) -> Vec { + if self.phase != Phase::Open { + return Vec::new(); + } + match (self.gate.poll(now_us), self.slot()) { + (Some(state), Some(slot)) => { + vec![event(Message::SharePad(share_pad( + slot, + self.sequence, + state, + )))] + } + _ => Vec::new(), + } + } +} + +fn share_pad(slot: u8, sequence: u16, state: PadState) -> SharePad { + SharePad { + slot, + sequence, + buttons: state.buttons, + lx: state.lx, + ly: state.ly, + rx: state.rx, + ry: state.ry, + l2: state.l2, + r2: state.r2, + } +} + +#[cfg(test)] +mod tests { + use anyplay_share::pad::{PadDatagram, PadSender, button}; + + use super::*; + + fn ring() -> RingReady { + RingReady { + name: "anyplay-00112233445566ff".into(), + bytes: 123_456, + width: 1280, + height: 720, + max_fps: 60, + } + } + + /// The messages among the bridge frames of `steps`. + fn sent(steps: &[Step]) -> Vec { + steps + .iter() + .filter_map(|s| match s { + Step::Bridge(f) => f.message().unwrap(), + Step::Event(_) => None, + }) + .collect() + } + + fn events(steps: &[Step]) -> Vec<&Event> { + steps + .iter() + .filter_map(|s| match s { + Step::Event(e) => Some(e), + Step::Bridge(_) => None, + }) + .collect() + } + + fn opened(host: &mut ShareHost) { + let reply = Frame::numbered( + &Message::ShareOpened { + result: ShareResult::Copying, + }, + 1, + ) + .unwrap(); + assert_eq!(events(&host.on_bridge(&reply)), [&Event::Opened]); + } + + fn started(mode: ShareMode) -> ShareHost { + let (mut host, _) = ShareHost::open(0, &ring(), mode); + opened(&mut host); + host + } + + fn datagram(sender: &mut PadSender, buttons: u32) -> Vec { + sender.next(PadState { + buttons, + ..PadState::NEUTRAL + }) + } + + #[test] + fn opening_asks_the_game_to_fill_the_ring_the_worker_made() { + let (host, steps) = ShareHost::open(0, &ring(), ShareMode::Together); + let [Step::Bridge(frame)] = steps.as_slice() else { + panic!("one bridge frame, got {steps:?}"); + }; + assert_eq!(frame.request, 1, "a request, so that it is answered"); + assert_eq!( + frame.message().unwrap(), + Some(Message::ShareOpen(ShareOpen { + ring: ring().name, + ring_bytes: 123_456, + width: 1280, + height: 720, + max_fps: 60, + mode: ShareMode::Together, + })) + ); + assert!(!host.is_open() && !host.is_closed()); + } + + #[test] + fn the_game_says_whether_it_copies() { + let (mut host, _) = ShareHost::open(0, &ring(), ShareMode::Watch); + let reply = + |result, request| Frame::numbered(&Message::ShareOpened { result }, request).unwrap(); + // An answer to another request is not ours. + assert!(host.on_bridge(&reply(ShareResult::Copying, 9)).is_empty()); + assert!(!host.is_open()); + let steps = host.on_bridge(&reply(ShareResult::Copying, 1)); + assert_eq!(events(&steps), [&Event::Opened]); + assert!(host.is_open()); + + for refusal in [ + ShareResult::RingUnavailable, + ShareResult::Running, + ShareResult::NotPresenting, + ShareResult::Unknown, + ] { + let (mut host, _) = ShareHost::open(0, &ring(), ShareMode::Watch); + let steps = host.on_bridge(&reply(refusal, 1)); + assert_eq!(events(&steps), [&Event::OpenFailed(refusal)]); + assert!(host.is_closed()); + } + } + + #[test] + fn a_pad_state_reaches_the_game_on_the_slot_of_the_mode() { + for (mode, slot) in [(ShareMode::HandOver, 0u8), (ShareMode::Together, 1)] { + let mut host = started(mode); + let mut sender = PadSender::new(); + let steps = host.on_pad_datagram(1_000, &datagram(&mut sender, button::SOUTH)); + assert_eq!( + sent(&steps), + [Message::SharePad(SharePad { + slot, + sequence: 0, + buttons: button::SOUTH, + lx: 128, + ly: 128, + rx: 128, + ry: 128, + l2: 0, + r2: 0, + })] + ); + } + } + + #[test] + fn watch_and_a_share_that_is_not_open_pass_no_pad() { + let mut sender = PadSender::new(); + let mut watch = started(ShareMode::Watch); + assert!( + watch + .on_pad_datagram(1_000, &datagram(&mut sender, button::SOUTH)) + .is_empty() + ); + let (mut opening, _) = ShareHost::open(0, &ring(), ShareMode::HandOver); + assert!( + opening + .on_pad_datagram(1_000, &datagram(&mut sender, button::SOUTH)) + .is_empty() + ); + let mut closed = started(ShareMode::HandOver); + closed.close(); + assert!( + closed + .on_pad_datagram(1_000, &datagram(&mut sender, button::SOUTH)) + .is_empty() + ); + } + + #[test] + fn malformed_and_reserved_datagrams_reach_no_game() { + let mut host = started(ShareMode::HandOver); + let mut good = PadSender::new().next(PadState::NEUTRAL); + good[5] = 0x80; // a reserved bit + assert!(host.on_pad_datagram(1_000, &good).is_empty()); + assert!(host.on_pad_datagram(2_000, b"not a pad").is_empty()); + assert!(host.on_pad_datagram(3_000, &[]).is_empty()); + } + + #[test] + fn many_invalid_datagrams_end_the_guest_connection() { + let mut host = started(ShareMode::HandOver); + let mut closed = false; + for i in 0..40u64 { + let steps = host.on_pad_datagram(i * 1_000, b"nope"); + if events(&steps).contains(&&Event::CloseGuest(7)) { + closed = true; + assert!(i >= 20); + break; + } + } + assert!(closed); + } + + #[test] + fn silence_gives_the_game_one_neutral_state_on_the_guests_slot() { + let mut host = started(ShareMode::Together); + let mut sender = PadSender::new(); + host.on_pad_datagram(1_000_000, &datagram(&mut sender, button::SOUTH)); + assert!(host.tick(1_100_000).is_empty()); + let steps = host.tick(1_250_000); + assert_eq!( + sent(&steps), + [Message::SharePad(SharePad::neutral(1, 0))], + "the neutral state is sent on pad 2 with the last sequence" + ); + assert!(host.tick(2_000_000).is_empty(), "once"); + } + + #[test] + fn leaving_a_pad_mode_neutralises_the_pad_before_the_mode_changes() { + let mut host = started(ShareMode::HandOver); + let mut sender = PadSender::new(); + host.on_pad_datagram(1_000, &datagram(&mut sender, button::SOUTH)); + let steps = host.set_mode(ShareMode::Watch); + assert_eq!( + sent(&steps), + [ + Message::SharePad(SharePad::neutral(0, 0)), + Message::ShareMode { + mode: ShareMode::Watch + }, + ] + ); + assert_eq!(events(&steps), [&Event::Mode(ShareMode::Watch)]); + // Watch: nothing the guest sends gets through any more. + assert!( + host.on_pad_datagram(2_000, &datagram(&mut sender, button::EAST)) + .is_empty() + ); + assert!(host.set_mode(ShareMode::Watch).is_empty(), "no change"); + } + + #[test] + fn going_from_hand_over_to_together_moves_the_guest_to_pad_two() { + let mut host = started(ShareMode::HandOver); + let mut sender = PadSender::new(); + host.on_pad_datagram(1_000, &datagram(&mut sender, button::SOUTH)); + let steps = host.set_mode(ShareMode::Together); + assert_eq!( + sent(&steps), + [ + Message::SharePad(SharePad::neutral(0, 0)), + Message::ShareMode { + mode: ShareMode::Together + }, + ] + ); + // The viewer's numbering goes on; the gate starts again for the new mode. + let steps = host.on_pad_datagram(2_000, &datagram(&mut sender, button::EAST)); + let messages = sent(&steps); + let [Message::SharePad(pad)] = messages.as_slice() else { + panic!("one pad state"); + }; + assert_eq!((pad.slot, pad.buttons), (1, button::EAST)); + } + + #[test] + fn the_host_taking_control_back_makes_it_watch_everywhere() { + let mut host = started(ShareMode::Together); + let took = Frame::event(&Message::Override { + action: OverrideAction::TookControl, + }) + .unwrap(); + let steps = host.on_bridge(&took); + assert_eq!( + sent(&steps), + [ + Message::SharePad(SharePad::neutral(1, 0)), + Message::ShareMode { + mode: ShareMode::Watch + }, + ] + ); + assert_eq!(events(&steps), [&Event::TookControl]); + assert_eq!(host.mode(), ShareMode::Watch); + let mut sender = PadSender::new(); + assert!( + host.on_pad_datagram(1_000, &datagram(&mut sender, button::SOUTH)) + .is_empty() + ); + // While only watching, the chord is a plain take-back: nothing to change. + let steps = host.on_bridge(&took); + assert!(sent(&steps).is_empty()); + } + + #[test] + fn the_stop_key_asks_to_stop() { + let mut host = started(ShareMode::HandOver); + let stop = Frame::event(&Message::Override { + action: OverrideAction::Stop, + }) + .unwrap(); + let steps = host.on_bridge(&stop); + assert_eq!(events(&steps), [&Event::StopRequested]); + assert!(sent(&steps).is_empty(), "the caller decides how to stop"); + } + + #[test] + fn the_status_passes_up() { + let mut host = started(ShareMode::Watch); + let status = ShareStatus { + frames: 60, + dropped: 1, + audio_underruns: 0, + }; + let steps = host.on_bridge(&Frame::event(&Message::ShareStatus(status)).unwrap()); + assert_eq!(events(&steps), [&Event::Status(status)]); + } + + #[test] + fn frames_before_the_game_answered_are_ignored() { + let (mut host, _) = ShareHost::open(0, &ring(), ShareMode::HandOver); + let took = Frame::event(&Message::Override { + action: OverrideAction::Stop, + }) + .unwrap(); + assert!(host.on_bridge(&took).is_empty()); + let status = Frame::event(&Message::ShareStatus(ShareStatus { + frames: 1, + dropped: 0, + audio_underruns: 0, + })) + .unwrap(); + assert!(host.on_bridge(&status).is_empty()); + // Frames of kinds this version does not know are skipped. + let unknown = Frame { + kind: anyplay_bridge_protocol::Kind(0x05FF), + request: 0, + payload: vec![1, 2, 3], + }; + assert!(host.on_bridge(&unknown).is_empty()); + } + + #[test] + fn closing_neutralises_then_tells_the_game_to_stop_copying() { + let mut host = started(ShareMode::Together); + let steps = host.close(); + assert_eq!( + sent(&steps), + [ + Message::SharePad(SharePad::neutral(1, 0)), + Message::ShareClose + ] + ); + assert!(host.is_closed()); + assert!(host.close().is_empty(), "once"); + assert!(host.set_mode(ShareMode::Watch).is_empty()); + assert!(host.tick(10_000_000).is_empty()); + } + + #[test] + fn closing_in_watch_only_closes() { + let mut host = started(ShareMode::Watch); + assert_eq!(sent(&host.close()), [Message::ShareClose]); + } + + #[test] + fn a_lost_bridge_ends_the_share_without_talking_to_it() { + let mut host = started(ShareMode::HandOver); + let steps = host.bridge_lost(); + assert_eq!(events(&steps), [&Event::BridgeLost]); + assert!(sent(&steps).is_empty()); + assert!(host.is_closed()); + assert!(host.bridge_lost().is_empty()); + } + + #[test] + fn states_lost_on_the_way_are_filled_from_the_repeats() { + let mut host = started(ShareMode::HandOver); + let mut sender = PadSender::new(); + let first = datagram(&mut sender, button::SOUTH); + let _lost = datagram(&mut sender, button::EAST); + let third = datagram(&mut sender, button::WEST); + assert_eq!(sent(&host.on_pad_datagram(1_000, &first)).len(), 1); + let steps = host.on_pad_datagram(5_000, &third); + let buttons: Vec = sent(&steps) + .iter() + .map(|m| match m { + Message::SharePad(p) => p.buttons, + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + buttons, + [button::EAST, button::WEST], + "the release is not lost" + ); + assert!(PadDatagram::parse(&third).is_ok()); + } +} diff --git a/crates/net/tests/share_play.rs b/crates/net/tests/share_play.rs new file mode 100644 index 0000000..75ad09c --- /dev/null +++ b/crates/net/tests/share_play.rs @@ -0,0 +1,427 @@ +//! Share play between the net service's host side and a game: the real bridge listener on +//! loopback, a fake game that follows the engine's rules for the share kinds, the frame ring +//! made by the share worker's code and filled through a second mapping the way the engine does, +//! and a guest whose pad states are datagrams. +//! +//! No iroh and no cloud: those carry the datagrams and the invitation, and sit above +//! [`ShareHost`]. + +use std::time::{Duration, Instant}; + +use anyplay_bridge_protocol::{ + Frame, Message, OverrideAction, ShareMode, ShareResult, ShareStatus, VERSION, Welcome, +}; +use anyplay_fake_game::{Bridge, ShareEngine}; +use anyplay_net::bridge::{self, BridgeInput}; +use anyplay_net::shareplay::{Event, ShareHost, Step}; +use anyplay_share::link::RingReady; +use anyplay_share::pad::{PadSender, PadState, button}; +use anyplay_share::ring::{RingWriter, name_from_random}; +use anyplay_share_worker::host::{FrameSource, Next}; +use anyplay_share_worker::ring_source::{self, RingSource}; +use anyplay_share_worker::shm::Shm; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + +const WIDTH: u32 = 64; +const HEIGHT: u32 = 36; + +struct Stage { + host: ShareHost, + frames: UnboundedSender, + inputs: UnboundedReceiver, + /// `None` once the game has exited. + game: Option, + source: RingSource>, + ready: RingReady, + events: Vec, + start: Instant, +} + +fn ring_name() -> String { + let mut random = [3u8; 16]; + random[..4].copy_from_slice(&std::process::id().to_le_bytes()); + random[4..12].copy_from_slice( + &(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64) + .to_le_bytes(), + ); + name_from_random(random) +} + +/// Waits up to five seconds for `condition`. +async fn eventually(what: &str, mut condition: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while !condition() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +/// A game connected to a bridge listener, as the net service accepts it: the frames to send it +/// and the inputs from it. +async fn connect_game() -> ( + Bridge, + UnboundedSender, + UnboundedReceiver, +) { + let (tx, mut inputs) = mpsc::unbounded_channel(); + let port = bridge::listen(tx).await.expect("the listener"); + let endpoint = format!("127.0.0.1:{port}"); + let connecting = tokio::spawn(async move { Bridge::connect(&endpoint, "0a0b0c").await }); + let BridgeInput::Hello { + frames, + verdict, + token, + .. + } = inputs.recv().await.expect("a hello") + else { + panic!("the first input is the hello"); + }; + assert_eq!(token, [0x0a, 0x0b, 0x0c]); + let welcome = Message::Welcome(Welcome { + version: VERSION, + udp_port: 0, + tcp_port: 0, + stream_key: [0; 16], + }); + frames.send(Frame::event(&welcome).unwrap()).unwrap(); + verdict.send(Ok(())).unwrap(); + let game = connecting.await.unwrap().expect("the game connects"); + (game, frames, inputs) +} + +impl Stage { + /// A game connected to the bridge listener, a ring made by the worker's code, and a share + /// that has asked the game to fill it. Not answered yet. + async fn begin(mode: ShareMode, refuse: Option) -> Stage { + let (game, frames, inputs) = connect_game().await; + if let Some(result) = refuse { + game.refuse_share_open(result); + } + let (source, _sound, ready) = ring_source::create(&ring_name(), WIDTH, HEIGHT, 60).unwrap(); + let start = Instant::now(); + let (host, steps) = ShareHost::open(0, &ready, mode); + let mut stage = Stage { + host, + frames, + inputs, + game: Some(game), + source, + ready, + events: Vec::new(), + start, + }; + stage.run(steps); + stage + } + + /// `begin`, and the game has said "copying". + async fn open(mode: ShareMode) -> Stage { + let mut stage = Stage::begin(mode, None).await; + stage.pump_until(|s| s.host.is_open()).await; + stage + } + + fn game(&self) -> &Bridge { + self.game.as_ref().expect("the game is still there") + } + + fn now_us(&self) -> u64 { + self.start.elapsed().as_micros() as u64 + } + + /// Does what the steps say: frames to the bridge, events kept. + fn run(&mut self, steps: Vec) { + for step in steps { + match step { + Step::Bridge(frame) => { + let _ = self.frames.send(frame); + } + Step::Event(event) => self.events.push(event), + } + } + } + + /// Takes what the game sent, until `done`. + async fn pump_until(&mut self, done: impl Fn(&Stage) -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while !done(self) { + assert!( + Instant::now() < deadline, + "timed out; events {:?}", + self.events + ); + match tokio::time::timeout(Duration::from_millis(20), self.inputs.recv()).await { + Ok(Some(BridgeInput::Frame { frame, .. })) => { + let steps = self.host.on_bridge(&frame); + self.run(steps); + } + Ok(Some(BridgeInput::Closed { .. })) => { + let steps = self.host.bridge_lost(); + self.run(steps); + } + Ok(Some(BridgeInput::Hello { .. })) | Ok(None) | Err(_) => {} + } + } + } + + fn engine(&self) -> ShareEngine { + self.game().share_engine() + } + + /// Sends a pad datagram of the guest and waits until the game saw `count` states. + async fn guest_presses(&mut self, sender: &mut PadSender, buttons: u32, taken: usize) { + let datagram = sender.next(PadState { + buttons, + ..PadState::NEUTRAL + }); + let now = self.now_us(); + let steps = self.host.on_pad_datagram(now, &datagram); + self.run(steps); + let game = self.game(); + eventually("the game to take the state", || { + game.share_engine().taken.len() >= taken + }) + .await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_game_is_asked_for_the_workers_ring_and_fills_it() { + let mut stage = Stage::open(ShareMode::Watch).await; + assert_eq!(stage.events, [Event::Opened]); + let engine = stage.engine(); + let open = engine.opened.expect("the game took the open"); + assert_eq!(open.ring, stage.ready.name); + assert_eq!(open.ring_bytes, stage.ready.bytes); + assert_eq!((open.width, open.height, open.max_fps), (WIDTH, HEIGHT, 60)); + assert_eq!(open.mode, ShareMode::Watch); + + // The engine maps the ring by that name, checks it, and writes a picture and sound. + let memory = Shm::open(&open.ring, open.ring_bytes as usize).unwrap(); + let mut writer = + RingWriter::open(memory, open.width, open.height, open.ring_bytes as usize).unwrap(); + assert!(matches!(stage.source.next_frame(), Next::NotYet)); + let picture: Vec = (0..WIDTH * HEIGHT * 4).map(|i| (i % 251) as u8).collect(); + assert!(writer.push_picture(&picture, 1_000_000, 42)); + match stage.source.next_frame() { + Next::Picture(frame) => { + assert_eq!((frame.width, frame.height), (WIDTH, HEIGHT)); + assert_eq!(frame.bgra, picture); + assert_eq!(frame.timestamp_us, 1_000_000); + } + other => panic!("a picture from the ring, got {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_game_that_cannot_open_the_ring_ends_the_share_before_it_starts() { + let mut stage = Stage::begin(ShareMode::HandOver, Some(ShareResult::RingUnavailable)).await; + stage.pump_until(|s| s.host.is_closed()).await; + assert_eq!( + stage.events, + [Event::OpenFailed(ShareResult::RingUnavailable)] + ); + assert_eq!(stage.engine().opened, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn hand_over_puts_the_guests_pad_in_place_of_pad_one() { + let mut stage = Stage::open(ShareMode::HandOver).await; + let mut guest = PadSender::new(); + stage.guest_presses(&mut guest, button::SOUTH, 1).await; + let engine = stage.engine(); + let pad = engine.now[0].expect("pad 1 is the guest's"); + assert_eq!((pad.slot, pad.buttons), (0, button::SOUTH)); + assert_eq!(engine.now[1], None); + + // Another state, and the release. + stage + .guest_presses(&mut guest, button::SOUTH | button::R1, 2) + .await; + stage.guest_presses(&mut guest, 0, 3).await; + let engine = stage.engine(); + assert_eq!(engine.now[0].unwrap().buttons, 0); + assert_eq!(engine.refused, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn together_gives_the_guest_pad_two_and_leaves_pad_one_alone() { + let mut stage = Stage::open(ShareMode::Together).await; + let mut guest = PadSender::new(); + stage.guest_presses(&mut guest, button::EAST, 1).await; + let engine = stage.engine(); + assert_eq!(engine.now[0], None); + let pad = engine.now[1].expect("pad 2 is the guest's"); + assert_eq!((pad.slot, pad.buttons), (1, button::EAST)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn watch_gives_the_guest_no_pad_at_all() { + let mut stage = Stage::open(ShareMode::Watch).await; + let mut guest = PadSender::new(); + let datagram = guest.next(PadState { + buttons: button::SOUTH, + ..PadState::NEUTRAL + }); + let now = stage.now_us(); + assert!(stage.host.on_pad_datagram(now, &datagram).is_empty()); + tokio::time::sleep(Duration::from_millis(100)).await; + let engine = stage.engine(); + assert!(engine.taken.is_empty()); + assert_eq!(engine.now, [None, None]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_guest_who_falls_silent_is_neutral_in_the_game() { + let mut stage = Stage::open(ShareMode::HandOver).await; + let mut guest = PadSender::new(); + stage.guest_presses(&mut guest, button::SOUTH, 1).await; + let now = stage.now_us(); + assert!(stage.host.tick(now + 100_000).is_empty()); + let steps = stage.host.tick(now + 300_000); + stage.run(steps); + let game = stage.game(); + eventually("the neutral state", || { + game.share_engine().now[0].is_some_and(|p| p.buttons == 0) + }) + .await; + assert_eq!(stage.engine().taken.len(), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn changing_the_mode_moves_or_removes_the_guests_pad() { + let mut stage = Stage::open(ShareMode::HandOver).await; + let mut guest = PadSender::new(); + stage.guest_presses(&mut guest, button::SOUTH, 1).await; + + let steps = stage.host.set_mode(ShareMode::Together); + stage.run(steps); + let game = stage.game(); + eventually("the engine to follow the mode", || { + game.share_engine().mode == Some(ShareMode::Together) + }) + .await; + assert_eq!(stage.engine().now, [None, None], "pad 1 was taken away"); + stage.guest_presses(&mut guest, button::EAST, 3).await; + assert_eq!(stage.engine().now[1].unwrap().buttons, button::EAST); + + let steps = stage.host.set_mode(ShareMode::Watch); + stage.run(steps); + let game = stage.game(); + eventually("watch", || { + game.share_engine().mode == Some(ShareMode::Watch) + }) + .await; + assert_eq!(stage.engine().now, [None, None]); + assert_eq!(stage.events.last(), Some(&Event::Mode(ShareMode::Watch))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_host_taking_control_back_stops_the_guest_pad() { + let mut stage = Stage::open(ShareMode::Together).await; + let mut guest = PadSender::new(); + stage.guest_presses(&mut guest, button::SOUTH, 1).await; + + stage.game().send(&Message::Override { + action: OverrideAction::TookControl, + }); + stage + .pump_until(|s| s.events.contains(&Event::TookControl)) + .await; + let game = stage.game(); + eventually("watch in the engine", || { + game.share_engine().mode == Some(ShareMode::Watch) + }) + .await; + assert_eq!(stage.host.mode(), ShareMode::Watch); + assert_eq!(stage.engine().now, [None, None]); + + // Whatever the guest sends now goes nowhere. + let taken = stage.engine().taken.len(); + let datagram = guest.next(PadState { + buttons: button::NORTH, + ..PadState::NEUTRAL + }); + let now = stage.now_us(); + assert!(stage.host.on_pad_datagram(now, &datagram).is_empty()); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(stage.engine().taken.len(), taken); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_stop_key_and_the_status_reach_the_service() { + let mut stage = Stage::open(ShareMode::Watch).await; + let status = ShareStatus { + frames: 120, + dropped: 2, + audio_underruns: 0, + }; + stage.game().send(&Message::ShareStatus(status)); + stage + .pump_until(|s| s.events.contains(&Event::Status(status))) + .await; + stage.game().send(&Message::Override { + action: OverrideAction::Stop, + }); + stage + .pump_until(|s| s.events.contains(&Event::StopRequested)) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn closing_leaves_the_game_with_a_neutral_pad_and_no_ring() { + let mut stage = Stage::open(ShareMode::HandOver).await; + let mut guest = PadSender::new(); + stage.guest_presses(&mut guest, button::SOUTH, 1).await; + let steps = stage.host.close(); + stage.run(steps); + let game = stage.game(); + eventually("the game to see the close", || game.share_engine().closed).await; + assert_eq!(stage.engine().now, [None, None]); + // Late datagrams of the guest are not played. + let datagram = guest.next(PadState { + buttons: button::SOUTH, + ..PadState::NEUTRAL + }); + let now = stage.now_us(); + assert!(stage.host.on_pad_datagram(now, &datagram).is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_game_that_goes_away_ends_the_share() { + let mut stage = Stage::open(ShareMode::Together).await; + drop(stage.game.take()); + stage.pump_until(|s| s.host.is_closed()).await; + assert_eq!(stage.events.last(), Some(&Event::BridgeLost)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_second_share_while_one_runs_is_refused_by_the_game() { + let stage = Stage::open(ShareMode::Watch).await; + let ring2 = ring_source::create(&ring_name(), WIDTH, HEIGHT, 60) + .unwrap() + .2; + let (mut second, steps) = ShareHost::open(0, &ring2, ShareMode::Watch); + for step in steps { + if let Step::Bridge(frame) = step { + stage.frames.send(frame).unwrap(); + } + } + let mut stage = stage; + let mut answered = Vec::new(); + while answered.is_empty() { + let Ok(Some(BridgeInput::Frame { frame, .. })) = + tokio::time::timeout(Duration::from_secs(5), stage.inputs.recv()).await + else { + panic!("an answer"); + }; + answered.extend(second.on_bridge(&frame)); + } + assert_eq!( + answered, + [Step::Event(Event::OpenFailed(ShareResult::Running))] + ); +} diff --git a/crates/share-worker/Cargo.toml b/crates/share-worker/Cargo.toml index 5300f07..9b4ae77 100644 --- a/crates/share-worker/Cargo.toml +++ b/crates/share-worker/Cargo.toml @@ -39,6 +39,7 @@ windows = { version = "0.62", features = [ "Win32_Media_MediaFoundation", "Win32_Media_Multimedia", "Win32_Security", + "Win32_System_Memory", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Ole", @@ -64,6 +65,11 @@ pipewire = "0.10" openh264 = { version = "0.9", default-features = false, features = ["libloading"] } openh264-sys2 = { version = "0.9", default-features = false, features = ["libloading"] } +# The frame ring is shared memory by name: shm_open and mmap on Linux and the other Unixes +# (MIT or Apache-2.0); on Windows a named file mapping, from the `windows` crate above. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + # macOS: ScreenCaptureKit for the game's window and sound, VideoToolbox for the system's H.264 # encoder, and the permission check, all through the objc2 bindings (Zlib OR Apache-2.0 OR MIT, # the family cpal already uses). Nothing of a codec is carried (docs/adr/0036). diff --git a/crates/share-worker/src/input.rs b/crates/share-worker/src/input.rs new file mode 100644 index 0000000..8e9e8bb --- /dev/null +++ b/crates/share-worker/src/input.rs @@ -0,0 +1,226 @@ +//! The guest's pad, from the viewer's window: which keys make which buttons, and how often a +//! state may leave (`spec/share-play.md`, Input). +//! +//! Only the keys listed in [`PadKey`] are read, and only while the viewer's window has the +//! focus. What leaves is a [`PadState`] and nothing else: no key code, no text, no mouse. +//! +//! | Pad | Keys | +//! |---|---| +//! | Left stick | W A S D | +//! | Right stick | I J K L | +//! | D-pad | the arrow keys | +//! | South, east, west, north | Z, X, C, V | +//! | L1, R1 | Q, E | +//! | L2, R2 | 1, 3 | +//! | L3, R3 | F, G | +//! | Start, select, pad click | Enter, Tab, Backspace | + +use anyplay_share::pad::{MIDDLE, PadState, button}; + +/// A key that makes part of a pad state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PadKey { + W, + A, + S, + D, + I, + J, + K, + L, + Up, + Down, + Left, + Right, + Z, + X, + C, + V, + Q, + E, + Num1, + Num3, + F, + G, + Enter, + Tab, + Backspace, +} + +/// The state that the keys held down make. +pub fn pad_from_keys(down: impl Fn(PadKey) -> bool) -> PadState { + let axis = |negative: PadKey, positive: PadKey| match (down(negative), down(positive)) { + (true, false) => 0u8, + (false, true) => 255, + _ => MIDDLE, + }; + let mut buttons = 0; + for (key, bit) in [ + (PadKey::Up, button::DPAD_UP), + (PadKey::Down, button::DPAD_DOWN), + (PadKey::Left, button::DPAD_LEFT), + (PadKey::Right, button::DPAD_RIGHT), + (PadKey::Z, button::SOUTH), + (PadKey::X, button::EAST), + (PadKey::C, button::WEST), + (PadKey::V, button::NORTH), + (PadKey::Q, button::L1), + (PadKey::E, button::R1), + (PadKey::F, button::L3), + (PadKey::G, button::R3), + (PadKey::Enter, button::START), + (PadKey::Tab, button::SELECT), + (PadKey::Backspace, button::PAD_CLICK), + ] { + if down(key) { + buttons |= bit; + } + } + PadState { + buttons, + lx: axis(PadKey::A, PadKey::D), + // Up on a stick is the low end, as on a pad. + ly: axis(PadKey::W, PadKey::S), + rx: axis(PadKey::J, PadKey::L), + ry: axis(PadKey::I, PadKey::K), + l2: if down(PadKey::Num1) { 255 } else { 0 }, + r2: if down(PadKey::Num3) { 255 } else { 0 }, + } +} + +/// The fastest a changed state leaves: 250 a second. +pub const MIN_GAP_US: u64 = 4_000; +/// A state that did not change leaves this often, so that the host knows the viewer is alive. +pub const KEEP_ALIVE_US: u64 = 100_000; + +/// Decides when a state leaves the viewer. +#[derive(Debug, Default)] +pub struct PadPacer { + sent: Option<(u64, PadState)>, +} + +impl PadPacer { + pub fn new() -> PadPacer { + PadPacer::default() + } + + /// The state to send now, if one is due: a change at most every 4 ms, an unchanged state + /// every 100 ms. The first state always goes. + pub fn due(&mut self, now_us: u64, state: PadState) -> Option { + let send = match self.sent { + None => true, + Some((at, last)) => { + let since = now_us.saturating_sub(at); + (state != last && since >= MIN_GAP_US) || since >= KEEP_ALIVE_US + } + }; + if send { + self.sent = Some((now_us, state)); + Some(state) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(held: &[PadKey]) -> PadState { + pad_from_keys(|k| held.contains(&k)) + } + + #[test] + fn no_key_is_the_neutral_pad() { + assert_eq!(keys(&[]), PadState::NEUTRAL); + } + + #[test] + fn each_key_makes_its_part() { + assert_eq!(keys(&[PadKey::Z]).buttons, button::SOUTH); + assert_eq!(keys(&[PadKey::X]).buttons, button::EAST); + assert_eq!(keys(&[PadKey::C]).buttons, button::WEST); + assert_eq!(keys(&[PadKey::V]).buttons, button::NORTH); + assert_eq!(keys(&[PadKey::Up]).buttons, button::DPAD_UP); + assert_eq!(keys(&[PadKey::Right]).buttons, button::DPAD_RIGHT); + assert_eq!(keys(&[PadKey::Enter]).buttons, button::START); + assert_eq!(keys(&[PadKey::Tab]).buttons, button::SELECT); + assert_eq!( + keys(&[PadKey::Z, PadKey::Q]).buttons, + button::SOUTH | button::L1 + ); + assert_eq!(keys(&[PadKey::Num1]).l2, 255); + assert_eq!(keys(&[PadKey::Num3]).r2, 255); + let s = keys(&[PadKey::A, PadKey::S, PadKey::L, PadKey::I]); + assert_eq!((s.lx, s.ly, s.rx, s.ry), (0, 255, 255, 0)); + } + + #[test] + fn opposite_keys_cancel_and_nothing_sets_a_reserved_bit() { + let s = keys(&[PadKey::A, PadKey::D, PadKey::W, PadKey::S]); + assert_eq!((s.lx, s.ly), (MIDDLE, MIDDLE)); + let all = [ + PadKey::W, + PadKey::A, + PadKey::S, + PadKey::D, + PadKey::I, + PadKey::J, + PadKey::K, + PadKey::L, + PadKey::Up, + PadKey::Down, + PadKey::Left, + PadKey::Right, + PadKey::Z, + PadKey::X, + PadKey::C, + PadKey::V, + PadKey::Q, + PadKey::E, + PadKey::Num1, + PadKey::Num3, + PadKey::F, + PadKey::G, + PadKey::Enter, + PadKey::Tab, + PadKey::Backspace, + ]; + let everything = keys(&all); + assert!(everything.is_valid()); + assert_eq!(everything.buttons.count_ones(), 15); + } + + #[test] + fn the_first_state_goes_and_a_change_waits_for_the_gap() { + let mut pacer = PadPacer::new(); + let a = keys(&[PadKey::Z]); + let b = keys(&[PadKey::X]); + assert_eq!(pacer.due(0, a), Some(a)); + assert_eq!(pacer.due(1_000, b), None, "250 a second at most"); + assert_eq!(pacer.due(3_999, b), None); + assert_eq!(pacer.due(4_000, b), Some(b)); + } + + #[test] + fn an_unchanged_state_is_repeated_every_100_ms() { + let mut pacer = PadPacer::new(); + let a = keys(&[PadKey::Z]); + assert_eq!(pacer.due(0, a), Some(a)); + for t in (1_000..100_000).step_by(1_000) { + assert_eq!(pacer.due(t, a), None, "{t}"); + } + assert_eq!(pacer.due(100_000, a), Some(a)); + assert_eq!(pacer.due(150_000, a), None); + assert_eq!(pacer.due(200_000, a), Some(a)); + } + + #[test] + fn a_release_is_never_held_back_longer_than_the_gap() { + let mut pacer = PadPacer::new(); + let held = keys(&[PadKey::Z]); + pacer.due(0, held); + assert_eq!(pacer.due(4_000, PadState::NEUTRAL), Some(PadState::NEUTRAL)); + } +} diff --git a/crates/share-worker/src/lib.rs b/crates/share-worker/src/lib.rs index e8e56fe..6330319 100644 --- a/crates/share-worker/src/lib.rs +++ b/crates/share-worker/src/lib.rs @@ -15,6 +15,7 @@ pub mod broadcast; pub mod capture; pub mod clips; pub mod host; +pub mod input; pub mod limits; #[cfg(windows)] pub mod loopback; @@ -27,6 +28,8 @@ pub mod oh264; pub mod pick; #[cfg(target_os = "linux")] pub mod portal; +pub mod ring_source; +pub mod shm; pub mod socket; pub mod sound; #[cfg(windows)] diff --git a/crates/share-worker/src/main.rs b/crates/share-worker/src/main.rs index 6297f78..158ec4a 100644 --- a/crates/share-worker/src/main.rs +++ b/crates/share-worker/src/main.rs @@ -5,8 +5,8 @@ //! ```text //! anyplay-share --capabilities //! anyplay-share --role host|guest [--link 127.0.0.1:PORT] [--token TOKEN] -//! [--source game|test_pattern] [--title TITLE] [--window HWND] [--pid PID]... -//! [--headless] +//! [--source game|test_pattern|ring] [--ring NAME] [--title TITLE] +//! [--window HWND] [--pid PID]... [--headless] [--pad] //! anyplay-share --clips SECONDS [--title TITLE] [--window HWND] [--pid PID]... //! anyplay-share --screen-access | --request-screen-access | --open-screen-settings //! ``` @@ -25,6 +25,9 @@ //! `--open-screen-settings` opens the page of System Settings where the player switches it on. //! Clips, broadcast and a stream end with `no_permission` while it is off. //! `--headless` draws no window on the viewer's side (tests on a machine without a desktop). +//! `--source ring` is share play: the worker makes the frame ring `--ring NAME` (sized by +//! `--width`, `--height` and `--fps`), says so on the link and streams what the game's engine +//! writes into it. `--pad` lets the viewer's window read the guest's pad from the keyboard. use std::process::ExitCode; @@ -45,6 +48,8 @@ fn main() -> ExitCode { let mut title = String::new(); let mut window: Option = None; let mut headless = false; + let mut pad = false; + let mut ring: Option = None; let mut pids: Vec = Vec::new(); let mut clips: Option = None; let mut broadcast = false; @@ -93,6 +98,8 @@ fn main() -> ExitCode { "--source" => source = args.next().unwrap_or_default(), "--title" => title = args.next().unwrap_or_default(), "--headless" => headless = true, + "--pad" => pad = true, + "--ring" => ring = args.next(), "--window" => window = args.next().and_then(|v| v.parse().ok()), "--pid" => pids.extend(args.next().and_then(|v| v.parse::().ok())), _ => {} @@ -116,6 +123,24 @@ fn main() -> ExitCode { } }; match role { + Role::Host if source == "ring" => { + let Some(name) = ring else { + return fail(port, "share play needs --ring NAME".into()); + }; + match anyplay_share_worker::ring_source::create(&name, shape.0, shape.1, shape.2) { + Ok((frames, sound, ready)) => { + // The net service hands the ring to the game when it reads this. + let _ = port.to_service.send(FromWorker::RingReady(ready)); + host::run_with_sound( + Box::new(frames), + Box::new(encoder_for), + Some(Box::new(sound)), + port, + ); + } + Err(reason) => return fail(port, format!("the frame ring: {reason}")), + } + } Role::Host => { if source == "game" && !access::state().allows_capture() { return fail(port, access::EXPLANATION.into()); @@ -129,7 +154,7 @@ fn main() -> ExitCode { Role::Guest => { let decoders: DecoderFactory = Box::new(decoder_for); let speakers = if headless { None } else { speakers() }; - let sink = sink(&title, headless, speakers.is_some()); + let sink = sink(&title, headless, speakers.is_some(), pad); viewer::run_with_sound(decoders, sink, speakers, port); } } @@ -206,19 +231,20 @@ fn speakers() -> Option> { /// The viewer's window: "title" and, without speakers, a note that there is no sound. #[cfg(any(windows, target_os = "linux"))] -fn sink(title: &str, headless: bool, sound: bool) -> Box { +fn sink(title: &str, headless: bool, sound: bool, pad: bool) -> Box { if headless { return Box::new(anyplay_share_worker::viewer::NullSink); } let name = if title.is_empty() { "AnyPlay" } else { title }; let note = if sound { "" } else { " (no sound)" }; - Box::new(anyplay_share_worker::window::WindowSink::new(&format!( - "{name} - AnyPlay Share screen{note}" - ))) + let kind = if pad { "Share play" } else { "Share screen" }; + let window = + anyplay_share_worker::window::WindowSink::new(&format!("{name} - AnyPlay {kind}{note}")); + Box::new(if pad { window.with_pad() } else { window }) } #[cfg(not(any(windows, target_os = "linux")))] -fn sink(_title: &str, _headless: bool, _sound: bool) -> Box { +fn sink(_title: &str, _headless: bool, _sound: bool, _pad: bool) -> Box { Box::new(anyplay_share_worker::viewer::NullSink) } diff --git a/crates/share-worker/src/ring_source.rs b/crates/share-worker/src/ring_source.rs new file mode 100644 index 0000000..091b871 --- /dev/null +++ b/crates/share-worker/src/ring_source.rs @@ -0,0 +1,167 @@ +//! The game's pictures and sound from the frame ring: the streamer's [`FrameSource`] and +//! [`SoundSource`] for share play (`spec/share-play.md`, Frame ring). The worker makes the ring; +//! the engine, told its name over the bridge, fills it. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyplay_share::codec::Frame; +use anyplay_share::link::RingReady; +use anyplay_share::ring::{Layout, Memory, RingReader}; + +use crate::host::{FrameSource, Next}; +use crate::shm::Shm; +use crate::sound::SoundSource; + +/// How long a call waits for something new in the ring. +const WAIT: Duration = Duration::from_millis(20); +/// How often it looks while it waits. +const LOOK: Duration = Duration::from_millis(1); + +/// Pictures from the ring. +pub struct RingSource { + reader: RingReader, + pixels: Vec, +} + +impl RingSource { + pub fn new(reader: RingReader) -> RingSource { + RingSource { + reader, + pixels: Vec::new(), + } + } + + /// Pictures the engine wrote that nobody took in time, or that were overwritten while they + /// were copied. + pub fn dropped(&self) -> u64 { + self.reader.dropped() + } +} + +impl FrameSource for RingSource { + /// The newest picture the engine wrote since the last call. A game that does not present + /// (a loading screen, a movie that the tap does not see) gives none: the call waits a + /// little and answers [`Next::NotYet`], so that a stop still gets through. + fn next_frame(&mut self) -> Next { + let deadline = Instant::now() + WAIT; + loop { + if let Some(picture) = self.reader.next_picture(&mut self.pixels) { + return Next::Picture(Frame { + width: picture.width, + height: picture.height, + // The engine's clock in microseconds, wrapping at 2^32 as the stream does. + timestamp_us: picture.present_us as u32, + bgra: std::mem::take(&mut self.pixels), + }); + } + if Instant::now() >= deadline { + return Next::NotYet; + } + std::thread::sleep(LOOK); + } + } +} + +/// The game's sound from the ring's audio part: 48 kHz stereo floats. +pub struct RingSound { + reader: RingReader, +} + +impl RingSound { + pub fn new(reader: RingReader) -> RingSound { + RingSound { reader } + } +} + +impl SoundSource for RingSound { + fn read(&mut self, out: &mut Vec) -> Result { + std::thread::sleep(Duration::from_millis(10)); + self.reader.read_audio(out); + Ok(true) + } +} + +/// The readers on a new ring and what the net service is told about it. +pub type NewRing = (RingSource>, RingSound>, RingReady); + +/// Makes the ring for a share and the two readers on it. The mapping goes away when the last of +/// them is dropped. +pub fn create(name: &str, width: u32, height: u32, max_fps: u32) -> Result { + let layout = Layout::new(width, height, max_fps).map_err(|e| e.to_string())?; + let bytes = layout.total_bytes(); + let memory = Arc::new(Shm::create(name, bytes)?); + let pictures = RingReader::create(memory.clone(), layout).map_err(|e| e.to_string())?; + let sound = RingReader::open(memory).map_err(|e| e.to_string())?; + let ready = RingReady { + name: name.to_owned(), + bytes: u32::try_from(bytes).map_err(|_| "the ring is larger than 4 GiB".to_owned())?, + width, + height, + max_fps, + }; + Ok((RingSource::new(pictures), RingSound::new(sound), ready)) +} + +#[cfg(test)] +mod tests { + use anyplay_share::ring::{RingWriter, name_from_random}; + + use super::*; + + fn name() -> String { + let mut random = [7u8; 16]; + random[..4].copy_from_slice(&std::process::id().to_le_bytes()); + random[4..12].copy_from_slice( + &(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64) + .to_le_bytes(), + ); + name_from_random(random) + } + + #[test] + fn the_engines_pictures_and_sound_come_out_of_the_workers_source() { + let name = name(); + let (mut source, mut sound, ready) = create(&name, 8, 4, 60).unwrap(); + assert_eq!((ready.width, ready.height, ready.max_fps), (8, 4, 60)); + assert_eq!(ready.name, name); + + // Nothing drawn yet: the source waits a little and says so. + assert!(matches!(source.next_frame(), Next::NotYet)); + + // The engine's side, as the net service told it in `share_open`. + let engine = Shm::open(&name, ready.bytes as usize).unwrap(); + let mut writer = RingWriter::open(engine, 8, 4, ready.bytes as usize).unwrap(); + let mut out = Vec::new(); + sound.read(&mut out).unwrap(); + + let picture = vec![0x40u8; 8 * 4 * 4]; + assert!(writer.push_picture(&picture, 5_000_000, 3)); + writer.push_audio(&[0.1, -0.1, 0.2, -0.2]); + match source.next_frame() { + Next::Picture(frame) => { + assert_eq!((frame.width, frame.height), (8, 4)); + assert_eq!(frame.timestamp_us, 5_000_000); + assert!(frame.is_well_formed()); + assert_eq!(frame.bgra, picture); + } + other => panic!("a picture, got {other:?}"), + } + assert!(sound.read(&mut out).unwrap()); + assert_eq!(out, [0.1, -0.1, 0.2, -0.2]); + assert_eq!(source.dropped(), 0); + } + + #[test] + fn a_ring_cannot_be_made_twice_and_odd_sizes_make_none() { + let name = name(); + let first = create(&name, 8, 4, 60).unwrap(); + assert!(create(&name, 8, 4, 60).is_err()); + drop(first); + assert!(create(&self::name(), 7, 4, 60).is_err()); + assert!(create("not a ring name", 8, 4, 60).is_err()); + } +} diff --git a/crates/share-worker/src/shm.rs b/crates/share-worker/src/shm.rs new file mode 100644 index 0000000..b38f58d --- /dev/null +++ b/crates/share-worker/src/shm.rs @@ -0,0 +1,381 @@ +//! Shared memory by name: the block the frame ring lives in (`spec/share-play.md`, Frame ring). +//! +//! The worker creates it ([`Shm::create`]) and the game's engine maps it by the same name; a +//! test opens it the way the engine does ([`Shm::open`]). On Windows it is a named file mapping +//! in the session's local namespace, on Linux and the other Unixes a `shm_open` object. Only +//! the user who runs the game can read it: Windows gives a new mapping the creator's access +//! rules, and the Unix object is made with mode 0600. +//! +//! This file holds all of the `unsafe` that the ring needs. The ring's logic is in +//! `anyplay_share::ring`, over the [`Memory`] that [`Shm`] implements. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyplay_share::ring::{Memory, name_is_valid}; + +/// A mapped block of shared memory. +pub struct Shm { + ptr: *mut u8, + len: usize, + os: os::Handle, +} + +// The mapping is plain memory that another process writes at the same time; the `u64` fields +// are read and written atomically and the rest is copied, so a `Shm` may move between threads +// and be shared by them. +unsafe impl Send for Shm {} +unsafe impl Sync for Shm {} + +impl Shm { + /// Makes a new block of `len` bytes, zeroed, with a name that only `name_is_valid` allows. + /// Fails when the name is in use: a ring is never shared between two shares. + pub fn create(name: &str, len: usize) -> Result { + check(name, len)?; + let (ptr, os) = os::create(name, len)?; + Ok(Shm { ptr, len, os }) + } + + /// Maps a block that exists, as the engine does. `len` is the size the mapping is expected + /// to have. + pub fn open(name: &str, len: usize) -> Result { + check(name, len)?; + let (ptr, os) = os::open(name, len)?; + Ok(Shm { ptr, len, os }) + } + + fn atomic(&self, offset: usize) -> &AtomicU64 { + assert!( + offset.is_multiple_of(8) && offset + 8 <= self.len, + "an atomic field at {offset} of {}", + self.len + ); + // SAFETY: the pointer is the start of a mapping of `len` bytes that lives as long as + // `self`, the offset is in it, and it is a multiple of 8 on a page-aligned mapping. + unsafe { &*self.ptr.add(offset).cast::() } + } +} + +fn check(name: &str, len: usize) -> Result<(), String> { + if !name_is_valid(name) { + return Err(format!("{name:?} is not a name for a frame ring")); + } + if len == 0 || len > u32::MAX as usize { + return Err(format!("a frame ring of {len} bytes")); + } + Ok(()) +} + +impl Drop for Shm { + fn drop(&mut self) { + os::release(self.ptr, self.len, &mut self.os); + } +} + +impl Memory for Shm { + fn len(&self) -> usize { + self.len + } + + fn load_u64(&self, offset: usize) -> u64 { + self.atomic(offset).load(Ordering::Acquire) + } + + fn store_u64(&self, offset: usize, value: u64) { + self.atomic(offset).store(value, Ordering::Release); + } + + fn read(&self, offset: usize, out: &mut [u8]) { + assert!(offset + out.len() <= self.len, "a read past the ring"); + // SAFETY: in bounds (checked) and the two ranges cannot overlap: `out` is memory of + // this process that is not the mapping. + unsafe { std::ptr::copy_nonoverlapping(self.ptr.add(offset), out.as_mut_ptr(), out.len()) } + } + + fn write(&self, offset: usize, data: &[u8]) { + assert!(offset + data.len() <= self.len, "a write past the ring"); + // SAFETY: as in `read`. + unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), self.ptr.add(offset), data.len()) } + } +} + +#[cfg(windows)] +mod os { + use windows::Win32::Foundation::{ + CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, INVALID_HANDLE_VALUE, + SetLastError, WIN32_ERROR, + }; + use windows::Win32::System::Memory::{ + CreateFileMappingW, FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, + OpenFileMappingW, PAGE_READWRITE, UnmapViewOfFile, + }; + use windows::core::HSTRING; + + pub struct Handle(HANDLE); + + fn map(handle: HANDLE, len: usize) -> Result<*mut u8, String> { + // SAFETY: a valid mapping handle; a view of `len` bytes from the start. + let view = unsafe { MapViewOfFile(handle, FILE_MAP_ALL_ACCESS, 0, 0, len) }; + if view.Value.is_null() { + Err(format!( + "the ring cannot be mapped: {}", + std::io::Error::last_os_error() + )) + } else { + Ok(view.Value.cast()) + } + } + + pub fn create(name: &str, len: usize) -> Result<(*mut u8, Handle), String> { + let wide = HSTRING::from(name); + // SAFETY: plain calls with a valid, owned name; the handle is closed in `release` or + // below on a failure. + unsafe { SetLastError(WIN32_ERROR(0)) }; + let handle = unsafe { + CreateFileMappingW( + INVALID_HANDLE_VALUE, + None, + PAGE_READWRITE, + (len as u64 >> 32) as u32, + len as u32, + &wide, + ) + } + .map_err(|e| format!("the ring cannot be created: {e}"))?; + // A mapping of that name that existed already is opened by CreateFileMappingW and the + // error code says so; a ring is never shared, so that is a refusal. + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + unsafe { + let _ = CloseHandle(handle); + } + return Err("a ring of this name exists already".into()); + } + match map(handle, len) { + Ok(ptr) => Ok((ptr, Handle(handle))), + Err(why) => { + unsafe { + let _ = CloseHandle(handle); + } + Err(why) + } + } + } + + pub fn open(name: &str, len: usize) -> Result<(*mut u8, Handle), String> { + let wide = HSTRING::from(name); + // SAFETY: as in `create`. + let handle = unsafe { OpenFileMappingW(FILE_MAP_ALL_ACCESS.0, false, &wide) } + .map_err(|e| format!("the ring cannot be opened: {e}"))?; + match map(handle, len) { + Ok(ptr) => Ok((ptr, Handle(handle))), + Err(why) => { + unsafe { + let _ = CloseHandle(handle); + } + Err(why) + } + } + } + + pub fn release(ptr: *mut u8, _len: usize, handle: &mut Handle) { + // SAFETY: the view and the handle are the ones made above and are released once. + unsafe { + let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: ptr.cast() }); + let _ = CloseHandle(handle.0); + } + } +} + +#[cfg(unix)] +mod os { + use std::ffi::CString; + + /// The name to remove again, for the side that made the block. + pub struct Handle(Option); + + fn cname(name: &str) -> Result { + CString::new(format!("/{name}")).map_err(|_| "a name with a zero byte".to_owned()) + } + + fn map(fd: libc::c_int, len: usize) -> Result<*mut u8, String> { + // SAFETY: `fd` is a descriptor of an object at least `len` bytes long. + let ptr = unsafe { + libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ) + }; + if ptr == libc::MAP_FAILED { + Err(format!( + "the ring cannot be mapped: {}", + std::io::Error::last_os_error() + )) + } else { + Ok(ptr.cast()) + } + } + + pub fn create(name: &str, len: usize) -> Result<(*mut u8, Handle), String> { + let cname = cname(name)?; + // SAFETY: plain libc calls on an owned, NUL-terminated name and a descriptor that is + // closed on every path. + unsafe { + let fd = libc::shm_open( + cname.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_RDWR, + 0o600, + ); + if fd < 0 { + return Err(format!( + "the ring cannot be created: {}", + std::io::Error::last_os_error() + )); + } + if libc::ftruncate(fd, len as libc::off_t) != 0 { + let why = std::io::Error::last_os_error(); + libc::close(fd); + libc::shm_unlink(cname.as_ptr()); + return Err(format!("the ring cannot be sized: {why}")); + } + let mapped = map(fd, len); + libc::close(fd); + match mapped { + Ok(ptr) => Ok((ptr, Handle(Some(cname)))), + Err(why) => { + libc::shm_unlink(cname.as_ptr()); + Err(why) + } + } + } + } + + pub fn open(name: &str, len: usize) -> Result<(*mut u8, Handle), String> { + let cname = cname(name)?; + // SAFETY: as in `create`. + unsafe { + let fd = libc::shm_open(cname.as_ptr(), libc::O_RDWR, 0); + if fd < 0 { + return Err(format!( + "the ring cannot be opened: {}", + std::io::Error::last_os_error() + )); + } + let mut stat: libc::stat = std::mem::zeroed(); + if libc::fstat(fd, &mut stat) != 0 || (stat.st_size as u64) < len as u64 { + libc::close(fd); + return Err("the ring is shorter than expected".into()); + } + let mapped = map(fd, len); + libc::close(fd); + mapped.map(|ptr| (ptr, Handle(None))) + } + } + + pub fn release(ptr: *mut u8, len: usize, handle: &mut Handle) { + // SAFETY: the mapping is the one made above and is released once. + unsafe { + libc::munmap(ptr.cast(), len); + if let Some(name) = handle.0.take() { + libc::shm_unlink(name.as_ptr()); + } + } + } +} + +#[cfg(not(any(windows, unix)))] +mod os { + pub struct Handle; + + pub fn create(_name: &str, _len: usize) -> Result<(*mut u8, Handle), String> { + Err("this system has no shared memory for the frame ring".into()) + } + + pub fn open(_name: &str, _len: usize) -> Result<(*mut u8, Handle), String> { + Err("this system has no shared memory for the frame ring".into()) + } + + pub fn release(_ptr: *mut u8, _len: usize, _handle: &mut Handle) {} +} + +#[cfg(test)] +mod tests { + use anyplay_share::ring::{Layout, RingReader, RingWriter, name_from_random}; + + use super::*; + + fn unique() -> String { + let mut random = [0u8; 16]; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + random[..8].copy_from_slice(&(now as u64).to_le_bytes()); + random[8..12].copy_from_slice(&std::process::id().to_le_bytes()); + name_from_random(random) + } + + #[test] + fn a_name_that_is_not_a_ring_name_makes_nothing() { + assert!(Shm::create("../etc/passwd", 4096).is_err()); + assert!(Shm::create("Local\\anyplay-x", 4096).is_err()); + assert!(Shm::create("anyplay-ok-name", 0).is_err()); + assert!(Shm::open("nothing", 4096).is_err()); + } + + #[test] + fn a_ring_made_by_the_worker_is_filled_by_a_second_mapping() { + let layout = Layout::new(8, 4, 60).unwrap(); + let name = unique(); + let worker = Shm::create(&name, layout.total_bytes()).unwrap(); + let mut reader = RingReader::create(worker, layout).unwrap(); + // The engine's side: another mapping of the same block, with its own pointer. + let engine = Shm::open(&name, layout.total_bytes()).unwrap(); + let mut writer = RingWriter::open(engine, 8, 4, layout.total_bytes()).unwrap(); + + let picture: Vec = (0..8 * 4 * 4).map(|i| i as u8).collect(); + assert!(writer.push_picture(&picture, 777, 5)); + let mut pixels = Vec::new(); + let got = reader.next_picture(&mut pixels).expect("a picture"); + assert_eq!((got.present_us, got.frame, got.seq), (777, 5, 1)); + assert_eq!(pixels, picture); + + writer.push_audio(&[0.5, -0.5]); + let mut sound = Vec::new(); + reader.read_audio(&mut sound); + writer.push_audio(&[0.25, -0.25]); + assert_eq!(reader.read_audio(&mut sound).frames, 1); + assert_eq!(sound, [0.25, -0.25]); + } + + #[test] + fn a_name_in_use_is_not_made_again_and_is_free_after_the_block_goes() { + let name = unique(); + let first = Shm::create(&name, 4096).unwrap(); + assert!(Shm::create(&name, 4096).is_err()); + drop(first); + #[cfg(unix)] + assert!(Shm::open(&name, 4096).is_err(), "removed with its owner"); + } + + #[test] + fn the_ring_holds_the_atomic_fields_across_threads() { + let name = unique(); + let shm = std::sync::Arc::new(Shm::create(&name, 4096).unwrap()); + let other = shm.clone(); + let writer = std::thread::spawn(move || { + for i in 1..=1000u64 { + other.store_u64(64, i); + } + }); + let mut last = 0; + while last < 1000 { + let seen = shm.load_u64(64); + assert!(seen >= last); + last = seen; + } + writer.join().unwrap(); + } +} diff --git a/crates/share-worker/src/viewer.rs b/crates/share-worker/src/viewer.rs index 2fe157d..7ce5e77 100644 --- a/crates/share-worker/src/viewer.rs +++ b/crates/share-worker/src/viewer.rs @@ -6,10 +6,12 @@ use std::time::{Duration, Instant}; use anyplay_share::codec::{CodecError, Frame, VideoDecoder}; use anyplay_share::control::{Key, Report}; use anyplay_share::link::{FromWorker, ToWorker, WorkerStats}; +use anyplay_share::pad::PadState; use anyplay_share::reassembler::Reassembler; use anyplay_share::wire::TYPE_AUDIO; use crate::Port; +use crate::input::PadPacer; use crate::sound::{SoundOut, SoundReceiver}; /// Where pictures go: a window, or a counter in tests. @@ -24,6 +26,12 @@ pub trait FrameSink { fn closed(&self) -> bool { false } + /// The guest's pad right now, when this sink reads one (share play, in a window that has + /// the focus). Share screen is watch only: its sinks answer `None` and no state ever + /// leaves the worker. + fn pad(&mut self) -> Option { + None + } } /// A sink that shows nothing. @@ -80,12 +88,18 @@ pub fn run_with_sound( let mut stats = WorkerStats::default(); let mut last_report = Instant::now(); let mut last_stats = Instant::now(); + let mut pacer = PadPacer::new(); loop { if sink.closed() { let _ = to_service.send(FromWorker::Ended("the window was closed".into())); return; } sink.idle(); + if let Some(state) = sink.pad().and_then(|s| pacer.due(now_us(&clock), s)) + && to_service.send(FromWorker::Pad(state)).is_err() + { + return; + } match from_service.recv_timeout(SLICE) { Ok(ToWorker::Start(start)) => { epoch = start.epoch; diff --git a/crates/share-worker/src/window.rs b/crates/share-worker/src/window.rs index db6de57..2c9b106 100644 --- a/crates/share-worker/src/window.rs +++ b/crates/share-worker/src/window.rs @@ -2,8 +2,10 @@ //! with its shape kept, and nothing else. Closing it (or Escape) leaves the stream. use anyplay_share::codec::Frame; +use anyplay_share::pad::PadState; use minifb::{Key, ScaleMode, Window, WindowOptions}; +use crate::input::{PadKey, pad_from_keys}; use crate::viewer::FrameSink; /// The window opens at the picture's size, but never larger than this, so that a 1080p stream @@ -16,6 +18,7 @@ pub struct WindowSink { buffer: Vec, size: (usize, usize), closed: bool, + pad: bool, } impl WindowSink { @@ -26,9 +29,16 @@ impl WindowSink { buffer: Vec::new(), size: (0, 0), closed: false, + pad: false, } } + /// The window also reads the guest's pad from the keyboard (share play, not share screen). + pub fn with_pad(mut self) -> WindowSink { + self.pad = true; + self + } + /// The window's own events; with `picture`, the last picture again. fn present(&mut self, picture: bool) { let (width, height) = self.size; @@ -105,4 +115,43 @@ impl FrameSink for WindowSink { fn closed(&self) -> bool { self.closed } + + fn pad(&mut self) -> Option { + if !self.pad { + return None; + } + let window = self.window.as_ref().filter(|w| w.is_active())?; + // Only the keys of the pad are asked about; a key of the window's own (Escape) is not. + Some(pad_from_keys(|key| window.is_key_down(minifb_key(key)))) + } +} + +fn minifb_key(key: PadKey) -> Key { + match key { + PadKey::W => Key::W, + PadKey::A => Key::A, + PadKey::S => Key::S, + PadKey::D => Key::D, + PadKey::I => Key::I, + PadKey::J => Key::J, + PadKey::K => Key::K, + PadKey::L => Key::L, + PadKey::Up => Key::Up, + PadKey::Down => Key::Down, + PadKey::Left => Key::Left, + PadKey::Right => Key::Right, + PadKey::Z => Key::Z, + PadKey::X => Key::X, + PadKey::C => Key::C, + PadKey::V => Key::V, + PadKey::Q => Key::Q, + PadKey::E => Key::E, + PadKey::Num1 => Key::Key1, + PadKey::Num3 => Key::Key3, + PadKey::F => Key::F, + PadKey::G => Key::G, + PadKey::Enter => Key::Enter, + PadKey::Tab => Key::Tab, + PadKey::Backspace => Key::Backspace, + } } diff --git a/crates/share/src/lib.rs b/crates/share/src/lib.rs index 6dfdaac..27ea903 100644 --- a/crates/share/src/lib.rs +++ b/crates/share/src/lib.rs @@ -7,6 +7,8 @@ //! behind [`VideoEncoder`] and [`VideoDecoder`]). //! //! * [`audio`]: the numbering of the game's sound and the viewer's jitter buffer. +//! * [`pad`]: the guest's pad state, the pad datagram, and the host's rules for it. +//! * [`ring`]: the layout of the frame ring between the game and the worker. //! * [`wire`]: the video and audio datagrams, with a [`wire::Refusal`] for each reason the //! receiver rules give. //! * [`packetiser`]: one encoded frame into datagrams with Reed-Solomon parity. @@ -42,9 +44,11 @@ pub mod codec; pub mod control; pub mod link; pub mod packetiser; +pub mod pad; pub mod pixels; pub mod rate; pub mod reassembler; +pub mod ring; mod rs; pub mod testcodec; pub mod wire; diff --git a/crates/share/src/link.rs b/crates/share/src/link.rs index d767115..24646c6 100644 --- a/crates/share/src/link.rs +++ b/crates/share/src/link.rs @@ -10,6 +10,7 @@ use std::io::{self, Read, Write}; use serde::{Deserialize, Serialize}; use crate::control::{CodecCaps, Key, Report}; +use crate::pad::PadState; /// The version of the share link a worker and a net service speak. pub const LINK_VERSION: u32 = 0; @@ -109,11 +110,26 @@ pub enum FromWorker { Report(Report), /// An IDR is needed from the streamer (viewer's worker). Key(Key), + /// One pad state of the guest (viewer's worker); the net service sends it on. + Pad(PadState), + /// The frame ring exists and can be handed to the game (streamer's worker). The worker + /// makes it when it starts with the ring as its source and says so before anything else. + RingReady(RingReady), Stats(WorkerStats), /// The worker ended (crashed, was closed, lost its source). Ended(String), } +/// The ring exists: the net service sends the game `share_open` with these numbers. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RingReady { + pub name: String, + pub bytes: u32, + pub width: u32, + pub height: u32, + pub max_fps: u32, +} + /// How a worker is doing, about twice a second. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerStats { @@ -149,10 +165,12 @@ pub const KIND_KEY: u16 = 0x0013; pub const KIND_DATAGRAM_OUT: u16 = 0x0014; pub const KIND_DATAGRAM_IN: u16 = 0x0015; pub const KIND_REPORT: u16 = 0x0016; +pub const KIND_PAD: u16 = 0x0017; pub const KIND_STATS: u16 = 0x0018; pub const KIND_STOP: u16 = 0x0019; pub const KIND_KEY_REQUEST: u16 = 0x001A; pub const KIND_ENDED: u16 = 0x001B; +pub const KIND_RING_READY: u16 = 0x001C; /// One frame of the link: `length: u32`, `kind: u16`, `flags: u16`, `request: u32`, payload; /// little-endian, as in the bridge protocol. @@ -275,6 +293,8 @@ impl FromWorker { FromWorker::Datagram(bytes) => Frame::new(KIND_DATAGRAM_OUT, bytes.clone()), FromWorker::Report(report) => Frame::json(KIND_REPORT, report), FromWorker::Key(key) => Frame::json(KIND_KEY_REQUEST, key), + FromWorker::Pad(state) => Frame::new(KIND_PAD, state.to_bytes().to_vec()), + FromWorker::RingReady(ready) => Frame::json(KIND_RING_READY, ready), FromWorker::Stats(stats) => Frame::json(KIND_STATS, stats), FromWorker::Ended(reason) => Frame::new(KIND_ENDED, reason.as_bytes().to_vec()), } @@ -289,6 +309,12 @@ impl FromWorker { KIND_KEY_REQUEST => serde_json::from_slice(&frame.payload) .ok() .map(FromWorker::Key), + KIND_PAD => PadState::from_bytes(&frame.payload) + .filter(PadState::is_valid) + .map(FromWorker::Pad), + KIND_RING_READY => serde_json::from_slice(&frame.payload) + .ok() + .map(FromWorker::RingReady), KIND_STATS => serde_json::from_slice(&frame.payload) .ok() .map(FromWorker::Stats), @@ -317,6 +343,18 @@ mod tests { } } + #[test] + fn a_pad_state_with_a_reserved_bit_is_not_read_from_the_link() { + let bad = PadState { + buttons: 1 << 20, + ..PadState::NEUTRAL + }; + let frame = Frame::new(KIND_PAD, bad.to_bytes().to_vec()); + assert_eq!(FromWorker::from_frame(&frame), None); + let short = Frame::new(KIND_PAD, vec![0; 9]); + assert_eq!(FromWorker::from_frame(&short), None); + } + #[test] fn messages_survive_the_wire() { let to = [ @@ -337,6 +375,22 @@ mod tests { let from = [ FromWorker::Datagram(vec![9; 1200]), FromWorker::Key(Key { epoch: 1, frame: 7 }), + FromWorker::Pad(PadState { + buttons: 0x4011, + lx: 0, + ly: 255, + rx: 128, + ry: 3, + l2: 9, + r2: 200, + }), + FromWorker::RingReady(RingReady { + name: "anyplay-ring-0123456789abcdef".into(), + bytes: 12_345_678, + width: 1280, + height: 720, + max_fps: 60, + }), FromWorker::Stats(WorkerStats { frames: 5, fps: 60, diff --git a/crates/share/src/pad.rs b/crates/share/src/pad.rs new file mode 100644 index 0000000..4b1e684 --- /dev/null +++ b/crates/share/src/pad.rs @@ -0,0 +1,786 @@ +//! The guest's pad (`spec/share-play.md`, Input): the pad state, the pad datagram, the guest's +//! sender that repeats older states, and the host's gate that applies the rules before a state +//! may reach the game. Pure: time comes in as microseconds from the caller's monotonic clock. +//! +//! Nothing but a pad state can be sent: a fixed list of buttons, two sticks and two triggers. +//! There is no home button, touch, motion, keyboard or mouse, so there is nothing in this +//! module that could reach the host's computer. + +use std::collections::VecDeque; +use std::fmt; + +use crate::wire::VERSION; + +/// The datagram type of a pad datagram. +pub const TYPE_PAD: u8 = 4; +/// Bytes in front of the states. +pub const HEADER_LEN: usize = 5; +/// Bytes of one state on the wire. +pub const STATE_LEN: usize = 10; +/// States in one datagram, at most: the newest and the two before it. +pub const MAX_STATES: usize = 3; +/// The bits of `buttons` that mean something; bits 15 to 31 must be 0. +pub const BUTTON_MASK: u32 = 0x7FFF; +/// The middle of a stick. +pub const MIDDLE: u8 = 128; + +/// States accepted per second. +pub const RATE_PER_SECOND: u64 = 300; +/// States accepted in one burst. +pub const BURST: u64 = 30; +/// Invalid datagrams in [`INVALID_WINDOW_US`] that end the connection (close code 7). +pub const INVALID_LIMIT: usize = 20; +/// The window in which invalid datagrams are counted: 5 seconds. +pub const INVALID_WINDOW_US: u64 = 5_000_000; +/// Silence after which the game gets a neutral state: 250 ms. +pub const SILENCE_US: u64 = 250_000; + +/// The names of the bits of [`PadState::buttons`]. +pub mod button { + pub const DPAD_UP: u32 = 1 << 0; + pub const DPAD_DOWN: u32 = 1 << 1; + pub const DPAD_LEFT: u32 = 1 << 2; + pub const DPAD_RIGHT: u32 = 1 << 3; + pub const SOUTH: u32 = 1 << 4; + pub const EAST: u32 = 1 << 5; + pub const WEST: u32 = 1 << 6; + pub const NORTH: u32 = 1 << 7; + pub const L1: u32 = 1 << 8; + pub const R1: u32 = 1 << 9; + pub const L3: u32 = 1 << 10; + pub const R3: u32 = 1 << 11; + pub const START: u32 = 1 << 12; + pub const SELECT: u32 = 1 << 13; + pub const PAD_CLICK: u32 = 1 << 14; +} + +/// One state of a pad, complete and not a change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PadState { + pub buttons: u32, + pub lx: u8, + pub ly: u8, + pub rx: u8, + pub ry: u8, + pub l2: u8, + pub r2: u8, +} + +impl PadState { + /// No buttons, sticks in the middle, triggers up. + pub const NEUTRAL: PadState = PadState { + buttons: 0, + lx: MIDDLE, + ly: MIDDLE, + rx: MIDDLE, + ry: MIDDLE, + l2: 0, + r2: 0, + }; + + /// True when no reserved bit is set. + pub fn is_valid(&self) -> bool { + self.buttons & !BUTTON_MASK == 0 + } + + /// The state as the link carries it between the worker and the net service, and as the + /// datagram carries it: `buttons` big-endian, then the six bytes. + pub fn to_bytes(&self) -> [u8; STATE_LEN] { + let b = self.buttons.to_be_bytes(); + [ + b[0], b[1], b[2], b[3], self.lx, self.ly, self.rx, self.ry, self.l2, self.r2, + ] + } + + /// Reads one state; `None` for a slice that is not exactly [`STATE_LEN`] bytes. A reserved + /// bit is not checked here: [`PadState::is_valid`] says. + pub fn from_bytes(b: &[u8]) -> Option { + let b: &[u8; STATE_LEN] = b.try_into().ok()?; + Some(PadState { + buttons: u32::from_be_bytes([b[0], b[1], b[2], b[3]]), + lx: b[4], + ly: b[5], + rx: b[6], + ry: b[7], + l2: b[8], + r2: b[9], + }) + } +} + +impl Default for PadState { + fn default() -> Self { + PadState::NEUTRAL + } +} + +/// Why a pad datagram is refused; each is one rule of the spec. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PadRefusal { + /// Shorter than the header, or the length is not the header plus `count` states. + Length, + /// Another version than 0. + Version(u8), + /// Not a pad datagram. + Type(u8), + /// `count` is 0 or above 3. + Count(u8), + /// A reserved button bit is set. + ReservedButtons, +} + +impl fmt::Display for PadRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PadRefusal::Length => write!(f, "pad datagram of the wrong length"), + PadRefusal::Version(v) => write!(f, "pad datagram version {v} is not supported"), + PadRefusal::Type(t) => write!(f, "datagram type {t} is not a pad"), + PadRefusal::Count(c) => write!(f, "{c} states in a pad datagram"), + PadRefusal::ReservedButtons => write!(f, "pad datagram sets reserved buttons"), + } + } +} + +impl std::error::Error for PadRefusal {} + +/// A pad datagram: the sequence of its newest state and up to three states, newest first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PadDatagram { + pub sequence: u16, + pub states: Vec, +} + +impl PadDatagram { + /// Reads a pad datagram, exactly: the version, the type, 1 to 3 states, the exact length + /// and no reserved button bit. + pub fn parse(b: &[u8]) -> Result { + if b.len() < HEADER_LEN { + return Err(PadRefusal::Length); + } + if b[0] != VERSION { + return Err(PadRefusal::Version(b[0])); + } + if b[1] != TYPE_PAD { + return Err(PadRefusal::Type(b[1])); + } + let count = b[4]; + if count == 0 || usize::from(count) > MAX_STATES { + return Err(PadRefusal::Count(count)); + } + if b.len() != HEADER_LEN + STATE_LEN * usize::from(count) { + return Err(PadRefusal::Length); + } + let mut states = Vec::with_capacity(usize::from(count)); + for chunk in b[HEADER_LEN..].as_chunks::().0 { + let state = PadState::from_bytes(chunk).ok_or(PadRefusal::Length)?; + if !state.is_valid() { + return Err(PadRefusal::ReservedButtons); + } + states.push(state); + } + Ok(PadDatagram { + sequence: u16::from_be_bytes([b[2], b[3]]), + states, + }) + } + + /// Writes the datagram. States beyond [`MAX_STATES`] are not written, and an empty list + /// writes nothing (`None`), as does a state with a reserved bit. + pub fn encode(&self) -> Option> { + if self.states.is_empty() || self.states.iter().any(|s| !s.is_valid()) { + return None; + } + let states = &self.states[..self.states.len().min(MAX_STATES)]; + let mut out = Vec::with_capacity(HEADER_LEN + STATE_LEN * states.len()); + out.push(VERSION); + out.push(TYPE_PAD); + out.extend_from_slice(&self.sequence.to_be_bytes()); + out.push(states.len() as u8); + for state in states { + out.extend_from_slice(&state.to_bytes()); + } + Some(out) + } +} + +/// The guest's side: numbers the states and repeats the two before the newest in every +/// datagram, so that one or two lost datagrams cost nothing. +#[derive(Debug, Default)] +pub struct PadSender { + sequence: u16, + started: bool, + previous: VecDeque, +} + +impl PadSender { + pub fn new() -> PadSender { + PadSender::default() + } + + /// The datagram for the next state. A state with a reserved bit is cleaned of it, so the + /// host never gets a reason to refuse what an honest viewer sends. + pub fn next(&mut self, state: PadState) -> Vec { + let state = PadState { + buttons: state.buttons & BUTTON_MASK, + ..state + }; + if self.started { + self.sequence = self.sequence.wrapping_add(1); + } + self.started = true; + let mut states = vec![state]; + states.extend(self.previous.iter().copied()); + self.previous.push_front(state); + self.previous.truncate(MAX_STATES - 1); + PadDatagram { + sequence: self.sequence, + states, + } + .encode() + .expect("a cleaned state is valid and the list is not empty") + } +} + +/// What the host's gate lets through when the mode allows pad input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PadMode { + /// No pad input is applied. + Watch, + /// The guest's state replaces the host's pad 1. + HandOver, + /// The guest's state is a second pad. + Together, +} + +impl PadMode { + /// The pad the guest plays on: 0 for pad 1, 1 for pad 2, `None` in `watch`. + pub fn slot(self) -> Option { + match self { + PadMode::Watch => None, + PadMode::HandOver => Some(0), + PadMode::Together => Some(1), + } + } +} + +/// Why a datagram changed nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dropped { + /// The mode is `watch`: dropped without a reply, and not counted as invalid. + Watch, + /// Its newest state is at or behind the newest one seen. + Stale, + /// More than 300 states a second, or a burst above 30. + TooFast, + /// It did not parse; counted towards the close. + Invalid(PadRefusal), +} + +/// What one datagram did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Gated { + /// The states to apply, oldest first, with their sequence numbers. + pub apply: Vec<(u16, PadState)>, + /// Why nothing (or fewer states) was applied. + pub dropped: Option, + /// More than 20 invalid datagrams in 5 seconds: the connection ends with code 7. + pub close: bool, +} + +impl Gated { + fn nothing(dropped: Dropped, close: bool) -> Gated { + Gated { + apply: Vec::new(), + dropped: Some(dropped), + close, + } + } +} + +/// The host's rules for pad datagrams (`spec/share-play.md`, Rules the host's net service +/// applies). One gate belongs to one share. +#[derive(Debug)] +pub struct PadGate { + mode: PadMode, + newest: Option, + /// Tokens in the bucket, in thousandths of a state. + tokens: u64, + refilled_us: u64, + invalid: VecDeque, + last_valid_us: Option, + neutral_sent: bool, +} + +impl PadGate { + /// A gate in `watch` mode. + pub fn new(now_us: u64) -> PadGate { + PadGate { + mode: PadMode::Watch, + newest: None, + tokens: BURST * 1_000, + refilled_us: now_us, + invalid: VecDeque::new(), + last_valid_us: None, + neutral_sent: false, + } + } + + pub fn mode(&self) -> PadMode { + self.mode + } + + /// Changes the mode. Whatever the guest held is forgotten with the old mode: the caller + /// sends the game a neutral state on the old pad, because the engine removes it. + pub fn set_mode(&mut self, mode: PadMode) { + if mode == self.mode { + return; + } + self.mode = mode; + self.newest = None; + self.last_valid_us = None; + self.neutral_sent = false; + } + + fn refill(&mut self, now_us: u64) { + let elapsed = now_us.saturating_sub(self.refilled_us); + self.refilled_us = now_us.max(self.refilled_us); + // RATE_PER_SECOND states a second is RATE_PER_SECOND thousandths a millisecond, that + // is RATE_PER_SECOND thousandths per 1000 microseconds. + let added = elapsed.saturating_mul(RATE_PER_SECOND) / 1_000; + self.tokens = (self.tokens + added).min(BURST * 1_000); + } + + fn note_invalid(&mut self, now_us: u64) -> bool { + self.invalid.push_back(now_us); + while let Some(&oldest) = self.invalid.front() { + if now_us.saturating_sub(oldest) > INVALID_WINDOW_US { + self.invalid.pop_front(); + } else { + break; + } + } + self.invalid.len() > INVALID_LIMIT + } + + /// Takes one datagram of the share connection. + pub fn on_datagram(&mut self, now_us: u64, bytes: &[u8]) -> Gated { + if self.mode == PadMode::Watch { + return Gated::nothing(Dropped::Watch, false); + } + let datagram = match PadDatagram::parse(bytes) { + Ok(d) => d, + Err(refusal) => { + let close = self.note_invalid(now_us); + return Gated::nothing(Dropped::Invalid(refusal), close); + } + }; + // How many of the datagram's states are new: state `i` has sequence `sequence - i`. + // The first datagram of a mode counts its newest state only: the repeats behind it + // belong to what the guest pressed before, perhaps on another pad. + let fresh = match self.newest { + None => 1, + Some(newest) => { + let ahead = datagram.sequence.wrapping_sub(newest) as i16; + if ahead <= 0 { + return Gated::nothing(Dropped::Stale, false); + } + datagram.states.len().min(ahead as usize) + } + }; + self.refill(now_us); + let wanted = fresh as u64 * 1_000; + let take = if self.tokens >= wanted { + fresh + } else if self.tokens >= 1_000 { + // Over the rate: keep the newest. + 1 + } else { + return Gated::nothing(Dropped::TooFast, false); + }; + self.tokens -= take as u64 * 1_000; + let dropped = (take < fresh).then_some(Dropped::TooFast); + let apply = (0..take) + .rev() + .map(|i| (datagram.sequence.wrapping_sub(i as u16), datagram.states[i])) + .collect(); + self.newest = Some(datagram.sequence); + self.last_valid_us = Some(now_us); + self.neutral_sent = false; + Gated { + apply, + dropped, + close: false, + } + } + + /// Call often (at least every 50 ms). After 250 ms without a valid datagram it gives the + /// neutral state once, and the engine keeps it until a new datagram arrives. + pub fn poll(&mut self, now_us: u64) -> Option { + if self.mode == PadMode::Watch || self.neutral_sent { + return None; + } + let last = self.last_valid_us?; + if now_us.saturating_sub(last) >= SILENCE_US { + self.neutral_sent = true; + return Some(PadState::NEUTRAL); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(buttons: u32, lx: u8) -> PadState { + PadState { + buttons, + lx, + ..PadState::NEUTRAL + } + } + + fn datagram(sequence: u16, states: &[PadState]) -> Vec { + PadDatagram { + sequence, + states: states.to_vec(), + } + .encode() + .unwrap() + } + + fn gate(mode: PadMode) -> PadGate { + let mut g = PadGate::new(0); + g.set_mode(mode); + g + } + + #[test] + fn the_layout_is_the_one_in_the_spec() { + let s = PadState { + buttons: 0x0000_4011, + lx: 1, + ly: 2, + rx: 3, + ry: 4, + l2: 5, + r2: 6, + }; + assert_eq!( + datagram(0x0102, &[s]), + [0, 4, 1, 2, 1, 0, 0, 0x40, 0x11, 1, 2, 3, 4, 5, 6] + ); + assert_eq!(datagram(0x0102, &[s, s, s]).len(), 5 + 30); + } + + #[test] + fn a_datagram_survives_the_round_trip() { + let states = [ + state(button::SOUTH, 0), + state(button::EAST, 255), + PadState::NEUTRAL, + ]; + let bytes = datagram(65_535, &states); + let back = PadDatagram::parse(&bytes).unwrap(); + assert_eq!(back.sequence, 65_535); + assert_eq!(back.states, states); + assert_eq!(back.encode().unwrap(), bytes); + } + + #[test] + fn the_parser_is_exact() { + let good = datagram(1, &[PadState::NEUTRAL]); + assert!(PadDatagram::parse(&good).is_ok()); + let mut v = good.clone(); + v[0] = 1; + assert_eq!(PadDatagram::parse(&v), Err(PadRefusal::Version(1))); + let mut t = good.clone(); + t[1] = 1; + assert_eq!(PadDatagram::parse(&t), Err(PadRefusal::Type(1))); + for count in [0u8, 4, 255] { + let mut c = good.clone(); + c[4] = count; + assert_eq!(PadDatagram::parse(&c), Err(PadRefusal::Count(count))); + } + let mut long = good.clone(); + long.push(0); + assert_eq!(PadDatagram::parse(&long), Err(PadRefusal::Length)); + assert_eq!(PadDatagram::parse(&good[..14]), Err(PadRefusal::Length)); + assert_eq!(PadDatagram::parse(&[]), Err(PadRefusal::Length)); + let mut reserved = good.clone(); + reserved[5] = 0x80; // bit 31 + assert_eq!( + PadDatagram::parse(&reserved), + Err(PadRefusal::ReservedButtons) + ); + let mut bit15 = good; + bit15[7] = 0x80; // bit 15 + assert_eq!(PadDatagram::parse(&bit15), Err(PadRefusal::ReservedButtons)); + } + + #[test] + fn a_state_with_a_reserved_bit_is_not_written() { + let bad = PadState { + buttons: 1 << 15, + ..PadState::NEUTRAL + }; + let d = PadDatagram { + sequence: 0, + states: vec![bad], + }; + assert_eq!(d.encode(), None); + assert_eq!( + PadDatagram { + sequence: 0, + states: vec![] + } + .encode(), + None + ); + } + + #[test] + fn the_sender_repeats_the_two_states_before_the_newest() { + let mut sender = PadSender::new(); + let a = state(button::SOUTH, 10); + let b = state(button::EAST, 20); + let c = state(button::WEST, 30); + let d = state(button::NORTH, 40); + let first = PadDatagram::parse(&sender.next(a)).unwrap(); + assert_eq!((first.sequence, first.states.len()), (0, 1)); + let second = PadDatagram::parse(&sender.next(b)).unwrap(); + assert_eq!((second.sequence, second.states), (1, vec![b, a])); + let third = PadDatagram::parse(&sender.next(c)).unwrap(); + assert_eq!((third.sequence, third.states), (2, vec![c, b, a])); + let fourth = PadDatagram::parse(&sender.next(d)).unwrap(); + assert_eq!((fourth.sequence, fourth.states), (3, vec![d, c, b])); + } + + #[test] + fn the_sender_cleans_reserved_bits_and_wraps() { + let mut sender = PadSender::new(); + sender.sequence = 65_535; + sender.started = true; + let dirty = PadState { + buttons: 0xFFFF_FFFF, + ..PadState::NEUTRAL + }; + let parsed = PadDatagram::parse(&sender.next(dirty)).unwrap(); + assert_eq!(parsed.sequence, 0); + assert_eq!(parsed.states[0].buttons, BUTTON_MASK); + } + + #[test] + fn watch_drops_pad_datagrams_without_counting_them() { + let mut g = gate(PadMode::Watch); + let out = g.on_datagram(1, &datagram(0, &[state(1, 0)])); + assert_eq!(out.dropped, Some(Dropped::Watch)); + assert!(out.apply.is_empty() && !out.close); + for i in 0..100 { + let out = g.on_datagram(2 + i, b"garbage"); + assert!(!out.close); + } + } + + #[test] + fn a_valid_state_is_applied_in_hand_over_and_together() { + for mode in [PadMode::HandOver, PadMode::Together] { + let mut g = gate(mode); + let s = state(button::SOUTH, 200); + let out = g.on_datagram(1_000, &datagram(5, &[s])); + assert_eq!(out.apply, vec![(5, s)]); + assert_eq!(out.dropped, None); + } + assert_eq!(PadMode::HandOver.slot(), Some(0)); + assert_eq!(PadMode::Together.slot(), Some(1)); + assert_eq!(PadMode::Watch.slot(), None); + } + + #[test] + fn older_states_in_a_datagram_fill_the_gap_oldest_first() { + let mut g = gate(PadMode::HandOver); + let (a, b, c, d) = (state(1, 1), state(2, 2), state(4, 3), state(8, 4)); + assert_eq!(g.on_datagram(0, &datagram(10, &[a])).apply, vec![(10, a)]); + // The datagram with 11 was lost; 12 repeats it. + let out = g.on_datagram(4_000, &datagram(12, &[c, b, a])); + assert_eq!(out.apply, vec![(11, b), (12, c)]); + // The next one repeats what is seen: only the newest is new. + let out = g.on_datagram(8_000, &datagram(13, &[d, c, b])); + assert_eq!(out.apply, vec![(13, d)]); + } + + #[test] + fn the_first_datagram_of_a_mode_counts_its_newest_state_only() { + let mut g = gate(PadMode::Together); + let (a, b, c) = (state(1, 1), state(2, 2), state(4, 3)); + let out = g.on_datagram(0, &datagram(40, &[c, b, a])); + assert_eq!(out.apply, vec![(40, c)], "the repeats are from before"); + } + + #[test] + fn a_state_behind_the_newest_is_dropped() { + let mut g = gate(PadMode::HandOver); + let s = state(1, 1); + assert!(!g.on_datagram(0, &datagram(100, &[s])).apply.is_empty()); + for sequence in [100u16, 99, 98, 100u16.wrapping_sub(30_000)] { + let out = g.on_datagram(1_000, &datagram(sequence, &[s, s, s])); + assert_eq!(out.dropped, Some(Dropped::Stale), "sequence {sequence}"); + assert!(out.apply.is_empty()); + } + } + + #[test] + fn the_sequence_wraps() { + let mut g = gate(PadMode::HandOver); + let (a, b) = (state(1, 1), state(2, 2)); + g.on_datagram(0, &datagram(65_535, &[a])); + let out = g.on_datagram(4_000, &datagram(0, &[b, a])); + assert_eq!(out.apply, vec![(0, b)]); + let out = g.on_datagram(8_000, &datagram(65_534, &[a])); + assert_eq!(out.dropped, Some(Dropped::Stale)); + } + + #[test] + fn the_rate_is_300_a_second_with_a_burst_of_30() { + let mut g = gate(PadMode::HandOver); + let s = state(1, 1); + // 40 states at one instant: the first 30 pass, the rest are dropped. + let mut passed = 0; + for i in 0..40u16 { + let out = g.on_datagram(0, &datagram(i, &[s])); + passed += out.apply.len(); + } + assert_eq!(passed, 30); + // After a second the bucket is full again, and 300 a second then go through. + let mut passed = 0; + for i in 0..300u64 { + let at = 1_000_000 + i * 1_000_000 / 300; + let out = g.on_datagram(at, &datagram(100 + i as u16, &[s])); + passed += out.apply.len(); + } + assert!(passed >= 295, "{passed}"); + // 600 a second is twice too many. + let mut g = gate(PadMode::HandOver); + let mut passed = 0; + for i in 0..1_200u64 { + let out = g.on_datagram(i * 1_000_000 / 600, &datagram(i as u16, &[s])); + passed += out.apply.len(); + } + assert!((600..=660).contains(&passed), "{passed}"); + } + + #[test] + fn over_the_rate_the_newest_state_is_kept() { + let mut g = gate(PadMode::HandOver); + let s = state(1, 1); + for i in 0..29u16 { + g.on_datagram(0, &datagram(i, &[s])); + } + // One token is left; three new states want three. + let (a, b, c) = (state(2, 2), state(4, 3), state(8, 4)); + let out = g.on_datagram(0, &datagram(32, &[c, b, a])); + assert_eq!(out.apply, vec![(32, c)]); + assert_eq!(out.dropped, Some(Dropped::TooFast)); + } + + #[test] + fn many_invalid_datagrams_close_the_connection() { + let mut g = gate(PadMode::HandOver); + for i in 0..INVALID_LIMIT as u64 { + let out = g.on_datagram(i * 1_000, b"nope"); + assert!(matches!(out.dropped, Some(Dropped::Invalid(_)))); + assert!(!out.close, "{i}"); + } + assert!(g.on_datagram(30_000, b"nope").close); + } + + #[test] + fn invalid_datagrams_spread_over_time_do_not_close() { + let mut g = gate(PadMode::HandOver); + for i in 0..200u64 { + // One every 400 ms: 12 or 13 in any 5 seconds. + assert!(!g.on_datagram(i * 400_000, b"nope").close, "{i}"); + } + } + + #[test] + fn silence_gives_one_neutral_state() { + let mut g = gate(PadMode::HandOver); + assert_eq!( + g.poll(10_000_000), + None, + "no state seen, nothing to neutralise" + ); + g.on_datagram(1_000_000, &datagram(1, &[state(button::SOUTH, 0)])); + assert_eq!(g.poll(1_100_000), None); + assert_eq!(g.poll(1_249_999), None); + assert_eq!(g.poll(1_250_000), Some(PadState::NEUTRAL)); + assert_eq!(g.poll(1_300_000), None, "only once"); + // A new datagram ends the silence and the next silence is neutral again. + g.on_datagram(2_000_000, &datagram(2, &[state(button::EAST, 0)])); + assert_eq!(g.poll(2_100_000), None); + assert_eq!(g.poll(2_250_000), Some(PadState::NEUTRAL)); + } + + #[test] + fn a_change_of_mode_forgets_the_old_stream() { + let mut g = gate(PadMode::HandOver); + g.on_datagram(0, &datagram(500, &[state(1, 1)])); + g.set_mode(PadMode::Watch); + assert_eq!(g.poll(1_000_000), None); + assert_eq!( + g.on_datagram(2_000, &datagram(501, &[state(1, 1)])).dropped, + Some(Dropped::Watch) + ); + g.set_mode(PadMode::Together); + // The viewer's numbering may restart: a lower sequence is fine again. + let out = g.on_datagram(3_000, &datagram(3, &[state(1, 1)])); + assert_eq!(out.apply.len(), 1); + } + + /// A small generator for the fixed-seed tests. + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + } + + #[test] + fn random_bytes_are_read_or_refused_and_read_ones_write_back() { + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + for _ in 0..20_000 { + let len = (rng.next() % 40) as usize; + let mut bytes: Vec = (0..len).map(|_| rng.next() as u8).collect(); + if len >= 2 && rng.next().is_multiple_of(2) { + bytes[0] = 0; + bytes[1] = TYPE_PAD; + } + if let Ok(d) = PadDatagram::parse(&bytes) { + assert_eq!(d.encode().unwrap(), bytes); + } + } + } + + #[test] + fn a_gate_never_applies_a_reserved_bit() { + let mut rng = Rng(7); + let mut g = gate(PadMode::Together); + for i in 0..20_000u64 { + let len = (rng.next() % 40) as usize; + let mut bytes: Vec = (0..len).map(|_| rng.next() as u8).collect(); + if len >= 2 { + bytes[0] = 0; + bytes[1] = TYPE_PAD; + } + let out = g.on_datagram(i * 10_000, &bytes); + for (_, s) in out.apply { + assert!(s.is_valid()); + } + } + } +} diff --git a/crates/share/src/ring.rs b/crates/share/src/ring.rs new file mode 100644 index 0000000..2c92565 --- /dev/null +++ b/crates/share/src/ring.rs @@ -0,0 +1,892 @@ +//! The frame ring between the game and the share worker (`spec/share-play.md`, Frame ring). +//! +//! The worker creates a block of shared memory and reads from it; the engine maps the block by +//! its name and writes the presented pictures and the game's sound into it. One producer, one +//! consumer, no locks: a slot is written with its `seq` set to 0, then the pixels, then the +//! real `seq`, and the reader checks the `seq` before and after it copies. +//! +//! This module holds the layout and both ends' logic over a [`Memory`], a few calls that a +//! block of shared memory (in the worker) or a plain vector (in tests) can answer. The +//! operating system's mapping, and with it every line of `unsafe`, stays outside the crate. +//! [`RingWriter`] is the engine's half: the engine in C++ implements the same file, and the +//! fake game of the tests uses this one. + +use std::fmt; + +/// The first eight bytes of the ring. +pub const MAGIC: [u8; 8] = *b"ANYSHR00"; +/// The layout this module writes and reads. +pub const LAYOUT_VERSION: u32 = 0; +/// Slots for pictures. +pub const SLOTS: u32 = 3; +/// Bytes of the ring's header: the fields below, then zeros up to this size. +pub const HEADER_LEN: usize = 64; +/// Bytes of a slot's header. +pub const SLOT_HEADER_LEN: usize = 32; +/// Pixel format 1: four bytes per pixel, blue first, SDR. +pub const FORMAT_BGRA8: u32 = 1; +/// Frames in the audio ring: about 340 ms of sound. +pub const AUDIO_FRAMES: u32 = 16_384; +/// Bytes of one stereo frame of 32-bit floats. +pub const AUDIO_FRAME_BYTES: usize = 8; +/// The largest picture: 4K. +pub const MAX_WIDTH: u32 = 3_840; +pub const MAX_HEIGHT: u32 = 2_160; +/// The highest frame rate a ring is made for. +pub const MAX_FPS: u32 = 240; +/// The longest name of a ring. +/// macOS limits the name of a `shm_open` object to 31 bytes with its leading `/` (PSHMNAMLEN), +/// so a name is 30 characters at most on every system. +pub const MAX_NAME: usize = 30; + +// Offsets in the header. +const OFF_MAGIC: usize = 0; +const OFF_VERSION: usize = 8; +const OFF_SLOTS: usize = 12; +const OFF_WIDTH: usize = 16; +const OFF_HEIGHT: usize = 20; +const OFF_STRIDE: usize = 24; +const OFF_FORMAT: usize = 28; +const OFF_FPS: usize = 32; +const OFF_AUDIO_FRAMES: usize = 36; +/// Where the engine writes how many audio frames it has written in all (a `u64`). +pub const OFF_AUDIO_WRITE: usize = 40; + +// Offsets in a slot header. +const SLOT_SEQ: usize = 0; +const SLOT_PRESENT: usize = 8; +const SLOT_KIND: usize = 16; +const SLOT_FRAME: usize = 20; + +/// Why a ring or its parts are refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RingError { + /// The picture is empty, odd in width or height, above 4K, or the frame rate is 0 or above + /// 240. + Size, + /// The memory is shorter than the header or the layout it describes. + TooShort, + /// The magic is not `ANYSHR00`. + Magic, + /// Another layout version, slot count or pixel format than this one. + Unsupported, + /// The numbers in the header do not fit together (a stride below the width, an audio ring + /// that is not a power of two). + Inconsistent, +} + +impl fmt::Display for RingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + RingError::Size => "the ring's picture size is out of range", + RingError::TooShort => "the memory is shorter than the ring", + RingError::Magic => "the memory is not a frame ring", + RingError::Unsupported => "the ring has another layout than this one", + RingError::Inconsistent => "the ring's header is inconsistent", + }) + } +} + +impl std::error::Error for RingError {} + +/// The shape of a ring: everything else (offsets, sizes) follows from it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Layout { + pub width: u32, + pub height: u32, + pub stride: u32, + pub max_fps: u32, + pub audio_frames: u32, +} + +impl Layout { + /// The layout for pictures of `width` by `height` with the usual stride and audio ring. + pub fn new(width: u32, height: u32, max_fps: u32) -> Result { + // Even sizes: video is coded in pairs of pixels, and an even size keeps every slot at a + // multiple of 8 bytes, where its 64-bit `seq` is read atomically. + if width == 0 + || height == 0 + || !width.is_multiple_of(2) + || !height.is_multiple_of(2) + || width > MAX_WIDTH + || height > MAX_HEIGHT + || max_fps == 0 + || max_fps > MAX_FPS + { + return Err(RingError::Size); + } + Ok(Layout { + width, + height, + stride: width * 4, + max_fps, + audio_frames: AUDIO_FRAMES, + }) + } + + /// Bytes of a slot: its header and the picture. + pub fn slot_bytes(&self) -> usize { + SLOT_HEADER_LEN + self.stride as usize * self.height as usize + } + + pub fn slot_offset(&self, slot: u32) -> usize { + HEADER_LEN + slot as usize * self.slot_bytes() + } + + pub fn audio_offset(&self) -> usize { + HEADER_LEN + SLOTS as usize * self.slot_bytes() + } + + /// The size of the whole mapping. + pub fn total_bytes(&self) -> usize { + self.audio_offset() + self.audio_frames as usize * AUDIO_FRAME_BYTES + } + + /// The header as it is written into a new ring. + pub fn header(&self) -> [u8; HEADER_LEN] { + let mut h = [0u8; HEADER_LEN]; + h[OFF_MAGIC..OFF_MAGIC + 8].copy_from_slice(&MAGIC); + let mut put = |at: usize, v: u32| h[at..at + 4].copy_from_slice(&v.to_le_bytes()); + put(OFF_VERSION, LAYOUT_VERSION); + put(OFF_SLOTS, SLOTS); + put(OFF_WIDTH, self.width); + put(OFF_HEIGHT, self.height); + put(OFF_STRIDE, self.stride); + put(OFF_FORMAT, FORMAT_BGRA8); + put(OFF_FPS, self.max_fps); + put(OFF_AUDIO_FRAMES, self.audio_frames); + h + } + + /// Reads and checks a header; the ring is `len` bytes long. + pub fn parse(header: &[u8], len: usize) -> Result { + if header.len() < HEADER_LEN { + return Err(RingError::TooShort); + } + if header[OFF_MAGIC..OFF_MAGIC + 8] != MAGIC { + return Err(RingError::Magic); + } + let get = |at: usize| { + u32::from_le_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]]) + }; + if get(OFF_VERSION) != LAYOUT_VERSION + || get(OFF_SLOTS) != SLOTS + || get(OFF_FORMAT) != FORMAT_BGRA8 + { + return Err(RingError::Unsupported); + } + let layout = Layout { + width: get(OFF_WIDTH), + height: get(OFF_HEIGHT), + stride: get(OFF_STRIDE), + max_fps: get(OFF_FPS), + audio_frames: get(OFF_AUDIO_FRAMES), + }; + Layout::new(layout.width, layout.height, layout.max_fps)?; + if layout.stride < layout.width * 4 + || layout.stride > MAX_WIDTH * 4 + || layout.audio_frames == 0 + || !layout.audio_frames.is_power_of_two() + || layout.audio_frames > 1 << 20 + { + return Err(RingError::Inconsistent); + } + if len < layout.total_bytes() { + return Err(RingError::TooShort); + } + Ok(layout) + } +} + +/// True for a name the net service may hand out and the engine may open: `anyplay-` and then +/// letters, digits, `-` and `_`, 30 characters at most (it must fit macOS). Nothing that could name a path or +/// another namespace. +pub fn name_is_valid(name: &str) -> bool { + name.len() <= MAX_NAME + && name.len() > "anyplay-".len() + && name.starts_with("anyplay-") + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// A ring name from 128 random bits, folded to 64 by FNV-1a: `anyplay-` and 16 hex digits, +/// 24 characters, which fits every system's limit for a shared memory name. +pub fn name_from_random(random: [u8; 16]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in random { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("anyplay-{hash:016x}") +} + +/// A block of memory that the other process writes at the same time. Reads and writes of the +/// `u64` fields are atomic with acquire and release order; the byte copies are plain. +pub trait Memory { + fn len(&self) -> usize; + /// Atomic load with acquire order; `offset` is a multiple of 8. + fn load_u64(&self, offset: usize) -> u64; + /// Atomic store with release order; `offset` is a multiple of 8. + fn store_u64(&self, offset: usize, value: u64); + fn read(&self, offset: usize, out: &mut [u8]); + fn write(&self, offset: usize, data: &[u8]); + + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl Memory for std::sync::Arc { + fn len(&self) -> usize { + (**self).len() + } + fn load_u64(&self, offset: usize) -> u64 { + (**self).load_u64(offset) + } + fn store_u64(&self, offset: usize, value: u64) { + (**self).store_u64(offset, value) + } + fn read(&self, offset: usize, out: &mut [u8]) { + (**self).read(offset, out) + } + fn write(&self, offset: usize, data: &[u8]) { + (**self).write(offset, data) + } +} + +impl Memory for &M { + fn len(&self) -> usize { + (**self).len() + } + fn load_u64(&self, offset: usize) -> u64 { + (**self).load_u64(offset) + } + fn store_u64(&self, offset: usize, value: u64) { + (**self).store_u64(offset, value) + } + fn read(&self, offset: usize, out: &mut [u8]) { + (**self).read(offset, out) + } + fn write(&self, offset: usize, data: &[u8]) { + (**self).write(offset, data) + } +} + +/// A picture taken out of the ring. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Picture { + pub width: u32, + pub height: u32, + /// `width * 4`: the copy has no padding. + pub stride: u32, + /// The engine's monotonic clock when the game presented it, in microseconds. + pub present_us: u64, + /// The game's flip count. + pub frame: u32, + /// The slot's `seq`: grows by one for every picture written. + pub seq: u64, +} + +/// The consumer: the share worker. +pub struct RingReader { + memory: M, + layout: Layout, + last_seq: u64, + audio_read: Option, + dropped: u64, +} + +/// What the audio ring gave. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct AudioRead { + /// Stereo frames appended to the buffer. + pub frames: usize, + /// Frames the engine overwrote before they were read. + pub lost: u64, +} + +impl RingReader { + /// Opens a ring that already has its header (the worker writes it with + /// [`RingReader::create`]; the engine only maps it). + pub fn open(memory: M) -> Result, RingError> { + let mut header = [0u8; HEADER_LEN]; + if memory.len() < HEADER_LEN { + return Err(RingError::TooShort); + } + memory.read(0, &mut header); + let layout = Layout::parse(&header, memory.len())?; + Ok(RingReader { + memory, + layout, + last_seq: 0, + audio_read: None, + dropped: 0, + }) + } + + /// Writes the header of a new, zeroed ring and opens it. + pub fn create(memory: M, layout: Layout) -> Result, RingError> { + if memory.len() < layout.total_bytes() { + return Err(RingError::TooShort); + } + memory.write(0, &layout.header()); + RingReader::open(memory) + } + + pub fn layout(&self) -> Layout { + self.layout + } + + /// Pictures that were overwritten before they were read, or torn while they were copied. + pub fn dropped(&self) -> u64 { + self.dropped + } + + /// The newest picture that was not taken yet, copied into `pixels` (resized to the + /// picture). `None` when there is none, or the copy was overwritten while it ran. + pub fn next_picture(&mut self, pixels: &mut Vec) -> Option { + let mut best: Option<(u32, u64)> = None; + for slot in 0..SLOTS { + let seq = self + .memory + .load_u64(self.layout.slot_offset(slot) + SLOT_SEQ); + if seq > self.last_seq && best.is_none_or(|(_, b)| seq > b) { + best = Some((slot, seq)); + } + } + let (slot, seq) = best?; + let base = self.layout.slot_offset(slot); + let mut head = [0u8; SLOT_HEADER_LEN]; + self.memory.read(base, &mut head); + let present_us = u64::from_le_bytes(head[SLOT_PRESENT..SLOT_PRESENT + 8].try_into().ok()?); + let kind = u32::from_le_bytes(head[SLOT_KIND..SLOT_KIND + 4].try_into().ok()?); + let frame = u32::from_le_bytes(head[SLOT_FRAME..SLOT_FRAME + 4].try_into().ok()?); + let skipped = seq - self.last_seq - 1; + if kind != 0 { + // A kind of slot this reader does not know (1 is reserved for a GPU handle). + self.last_seq = seq; + self.dropped += skipped + 1; + return None; + } + let (w, h, stride) = (self.layout.width, self.layout.height, self.layout.stride); + let row = w as usize * 4; + pixels.clear(); + pixels.resize(row * h as usize, 0); + for y in 0..h as usize { + self.memory.read( + base + SLOT_HEADER_LEN + y * stride as usize, + &mut pixels[y * row..(y + 1) * row], + ); + } + let again = self.memory.load_u64(base + SLOT_SEQ); + self.last_seq = seq; + if again != seq { + self.dropped += skipped + 1; + return None; + } + self.dropped += skipped; + Some(Picture { + width: w, + height: h, + stride: w * 4, + present_us, + frame, + seq, + }) + } + + /// Appends the sound written since the last call to `out` as interleaved stereo floats. + /// The first call starts at the present: sound from before the worker looked is not played. + pub fn read_audio(&mut self, out: &mut Vec) -> AudioRead { + let ring = u64::from(self.layout.audio_frames); + let written = self.memory.load_u64(OFF_AUDIO_WRITE); + let Some(read) = self.audio_read else { + self.audio_read = Some(written); + return AudioRead::default(); + }; + if written <= read { + return AudioRead::default(); + } + let mut lost = 0; + let mut read = read; + if written - read > ring { + lost = written - read - ring; + read = written - ring; + } + let count = (written - read) as usize; + let mut bytes = vec![0u8; count * AUDIO_FRAME_BYTES]; + let first = (read % ring) as usize; + let until_wrap = (ring as usize - first).min(count); + let base = self.layout.audio_offset(); + self.memory.read( + base + first * AUDIO_FRAME_BYTES, + &mut bytes[..until_wrap * AUDIO_FRAME_BYTES], + ); + if until_wrap < count { + self.memory + .read(base, &mut bytes[until_wrap * AUDIO_FRAME_BYTES..]); + } + out.extend( + bytes + .as_chunks::<4>() + .0 + .iter() + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])), + ); + self.audio_read = Some(written); + AudioRead { + frames: count, + lost, + } + } +} + +/// The producer: what the engine does. The C++ in AnyPS5 writes the same bytes. +pub struct RingWriter { + memory: M, + layout: Layout, + seq: u64, + audio_written: u64, +} + +impl RingWriter { + /// Maps a ring the worker made and checks it against what the net service asked for in + /// `share_open`: the sizes must match and the mapping must be at least `ring_bytes` long. + pub fn open( + memory: M, + width: u32, + height: u32, + ring_bytes: usize, + ) -> Result, RingError> { + if memory.len() < HEADER_LEN || memory.len() < ring_bytes { + return Err(RingError::TooShort); + } + let mut header = [0u8; HEADER_LEN]; + memory.read(0, &mut header); + let layout = Layout::parse(&header, memory.len())?; + if layout.width != width || layout.height != height { + return Err(RingError::Inconsistent); + } + let audio_written = memory.load_u64(OFF_AUDIO_WRITE); + Ok(RingWriter { + memory, + layout, + seq: 0, + audio_written, + }) + } + + pub fn layout(&self) -> Layout { + self.layout + } + + /// Writes one picture of exactly the ring's size over the oldest slot. Never waits. + pub fn push_picture(&mut self, bgra: &[u8], present_us: u64, frame: u32) -> bool { + let (h, stride) = (self.layout.height as usize, self.layout.stride as usize); + let row = self.layout.width as usize * 4; + if bgra.len() != row * h { + return false; + } + self.seq += 1; + let slot = ((self.seq - 1) % u64::from(SLOTS)) as u32; + let base = self.layout.slot_offset(slot); + self.memory.store_u64(base + SLOT_SEQ, 0); + for y in 0..h { + self.memory.write( + base + SLOT_HEADER_LEN + y * stride, + &bgra[y * row..(y + 1) * row], + ); + } + let mut head = [0u8; SLOT_HEADER_LEN - 8]; + head[SLOT_PRESENT - 8..SLOT_PRESENT].copy_from_slice(&present_us.to_le_bytes()); + head[SLOT_KIND - 8..SLOT_KIND - 4].copy_from_slice(&0u32.to_le_bytes()); + head[SLOT_FRAME - 8..SLOT_FRAME - 4].copy_from_slice(&frame.to_le_bytes()); + self.memory.write(base + 8, &head); + self.memory.store_u64(base + SLOT_SEQ, self.seq); + true + } + + /// Appends stereo frames of floats to the audio ring. + pub fn push_audio(&mut self, stereo: &[f32]) { + let ring = u64::from(self.layout.audio_frames); + let frames = stereo.len() / 2; + let base = self.layout.audio_offset(); + for (i, pair) in stereo.as_chunks::<2>().0.iter().enumerate() { + let at = ((self.audio_written + i as u64) % ring) as usize; + let mut frame = [0u8; AUDIO_FRAME_BYTES]; + frame[..4].copy_from_slice(&pair[0].to_le_bytes()); + frame[4..].copy_from_slice(&pair[1].to_le_bytes()); + self.memory.write(base + at * AUDIO_FRAME_BYTES, &frame); + } + self.audio_written += frames as u64; + self.memory.store_u64(OFF_AUDIO_WRITE, self.audio_written); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Plain memory for tests. `on_read` runs inside every byte copy out of the ring, so a test + /// can write into the ring while the reader copies. + struct Vec8 { + bytes: Mutex>, + reads: AtomicU64, + #[allow(clippy::type_complexity)] + hook: Mutex>>, + } + + impl Vec8 { + fn new(len: usize) -> Vec8 { + Vec8 { + bytes: Mutex::new(vec![0; len]), + reads: AtomicU64::new(0), + hook: Mutex::new(None), + } + } + } + + impl Memory for Vec8 { + fn len(&self) -> usize { + self.bytes.lock().unwrap().len() + } + fn load_u64(&self, offset: usize) -> u64 { + let b = self.bytes.lock().unwrap(); + u64::from_le_bytes(b[offset..offset + 8].try_into().unwrap()) + } + fn store_u64(&self, offset: usize, value: u64) { + self.bytes.lock().unwrap()[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + } + fn read(&self, offset: usize, out: &mut [u8]) { + let n = self.reads.fetch_add(1, Ordering::SeqCst); + let hook = self.hook.lock().unwrap().take(); + if let Some(mut hook) = hook { + hook(n, self); + *self.hook.lock().unwrap() = Some(hook); + } + let b = self.bytes.lock().unwrap(); + out.copy_from_slice(&b[offset..offset + out.len()]); + } + fn write(&self, offset: usize, data: &[u8]) { + self.bytes.lock().unwrap()[offset..offset + data.len()].copy_from_slice(data); + } + } + + fn layout() -> Layout { + Layout::new(4, 2, 60).unwrap() + } + + fn picture(fill: u8) -> Vec { + vec![fill; 4 * 4 * 2] + } + + #[test] + fn the_layout_is_the_one_in_the_spec() { + let l = layout(); + let h = l.header(); + assert_eq!(&h[..8], b"ANYSHR00"); + // layout version, slots, width, height, stride, format, fps, audio ring + let words: Vec = (2..10) + .map(|i| u32::from_le_bytes(h[i * 4..i * 4 + 4].try_into().unwrap())) + .collect(); + assert_eq!(words, [0, 3, 4, 2, 16, 1, 60, 16_384]); + assert!(h[40..].iter().all(|b| *b == 0)); + assert_eq!(l.slot_bytes(), 32 + 16 * 2); + assert_eq!(l.slot_offset(0), 64); + assert_eq!(l.slot_offset(2), 64 + 2 * 64); + assert_eq!(l.audio_offset(), 64 + 3 * 64); + assert_eq!(l.total_bytes(), 64 + 3 * 64 + 16_384 * 8); + } + + #[test] + fn a_header_reads_back_and_is_checked() { + let l = layout(); + assert_eq!(Layout::parse(&l.header(), l.total_bytes()), Ok(l)); + assert_eq!( + Layout::parse(&l.header(), l.total_bytes() - 1), + Err(RingError::TooShort) + ); + assert_eq!(Layout::parse(&[0; 10], 100), Err(RingError::TooShort)); + let mut bad = l.header(); + bad[0] = b'X'; + assert_eq!(Layout::parse(&bad, l.total_bytes()), Err(RingError::Magic)); + for (at, value) in [(8usize, 1u32), (12, 2), (28, 2)] { + let mut bad = l.header(); + bad[at..at + 4].copy_from_slice(&value.to_le_bytes()); + assert_eq!( + Layout::parse(&bad, l.total_bytes()), + Err(RingError::Unsupported) + ); + } + let mut stride = l.header(); + stride[24..28].copy_from_slice(&8u32.to_le_bytes()); + assert_eq!( + Layout::parse(&stride, l.total_bytes()), + Err(RingError::Inconsistent) + ); + let mut audio = l.header(); + audio[36..40].copy_from_slice(&1000u32.to_le_bytes()); + assert_eq!( + Layout::parse(&audio, l.total_bytes()), + Err(RingError::Inconsistent) + ); + let mut huge = l.header(); + huge[16..20].copy_from_slice(&100_000u32.to_le_bytes()); + assert_eq!(Layout::parse(&huge, usize::MAX), Err(RingError::Size)); + } + + #[test] + fn sizes_out_of_range_make_no_layout() { + assert!(Layout::new(0, 10, 60).is_err()); + assert!(Layout::new(10, 0, 60).is_err()); + assert!(Layout::new(3_841, 10, 60).is_err()); + assert!(Layout::new(10, 2_161, 60).is_err()); + assert!(Layout::new(10, 10, 0).is_err()); + assert!(Layout::new(10, 10, 241).is_err()); + assert!(Layout::new(11, 10, 60).is_err()); + assert!(Layout::new(10, 11, 60).is_err()); + // Every slot starts on a multiple of 8 bytes, for any even size. + for (w, h) in [ + (2, 2), + (4, 2), + (1280, 720), + (1366, 768), + (1920, 1080), + (3_840, 2_160), + ] { + let l = Layout::new(w, h, 60).unwrap(); + for slot in 0..SLOTS { + assert_eq!(l.slot_offset(slot) % 8, 0, "{w}x{h}"); + } + } + let big = Layout::new(3_840, 2_160, 60).unwrap(); + assert!(big.total_bytes() < u32::MAX as usize); + } + + #[test] + fn names_are_plain() { + assert!(name_is_valid("anyplay-ring-0123456789abcdef")); + assert!(name_is_valid(&name_from_random([0xAB; 16]))); + assert_eq!(name_from_random([0; 16]).len(), 8 + 16); + assert_ne!(name_from_random([1; 16]), name_from_random([2; 16])); + let long = format!("anyplay-{}", "a".repeat(23)); + for bad in [ + "", + "anyplay-", + "ring-1", + "anyplay-a/b", + "anyplay-a\\b", + "Global\\anyplay-x", + "anyplay-a b", + "anyplay-\u{e4}", + long.as_str(), + ] { + assert!(!name_is_valid(bad), "{bad:?}"); + } + } + + #[test] + fn a_name_fits_the_shm_open_limit_of_every_system() { + // macOS: 31 bytes with the leading slash (PSHMNAMLEN). + const PSHMNAMLEN: usize = 31; + assert!(name_from_random([0xFF; 16]).len() < PSHMNAMLEN); + const { assert!(MAX_NAME < PSHMNAMLEN) }; + let longest = format!("anyplay-{}", "z".repeat(MAX_NAME - "anyplay-".len())); + assert!(name_is_valid(&longest)); + assert!(!name_is_valid(&format!("{longest}z"))); + } + + #[test] + fn a_picture_goes_from_the_engine_to_the_worker() { + let mem = Vec8::new(layout().total_bytes()); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, layout().total_bytes()).unwrap(); + let mut pixels = Vec::new(); + assert_eq!(reader.next_picture(&mut pixels), None); + assert!(writer.push_picture(&picture(7), 123_456, 99)); + let got = reader.next_picture(&mut pixels).unwrap(); + assert_eq!( + ( + got.width, + got.height, + got.stride, + got.present_us, + got.frame, + got.seq + ), + (4, 2, 16, 123_456, 99, 1) + ); + assert_eq!(pixels, picture(7)); + assert_eq!(reader.next_picture(&mut pixels), None, "taken already"); + assert_eq!(reader.dropped(), 0); + } + + #[test] + fn the_reader_takes_the_newest_and_counts_what_it_skipped() { + let mem = Vec8::new(layout().total_bytes()); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + for i in 1..=5u8 { + writer.push_picture(&picture(i), i as u64, i as u32); + } + let mut pixels = Vec::new(); + let got = reader.next_picture(&mut pixels).unwrap(); + assert_eq!((got.seq, got.frame), (5, 5)); + assert_eq!(pixels, picture(5)); + assert_eq!(reader.dropped(), 4); + assert_eq!(reader.next_picture(&mut pixels), None); + } + + #[test] + fn the_ring_overwrites_the_oldest_and_never_waits() { + let mem = Vec8::new(layout().total_bytes()); + // No header yet: refused, and nothing was written. + assert_eq!( + RingWriter::open(&mem, 4, 2, 0).err(), + Some(RingError::Magic) + ); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + for i in 1..=3u8 { + writer.push_picture(&picture(i), 0, 0); + } + // Nobody reads; ten more pictures are written without a stop. + for i in 4..=13u8 { + assert!(writer.push_picture(&picture(i), 0, 0)); + } + let mut pixels = Vec::new(); + assert_eq!(reader.next_picture(&mut pixels).unwrap().seq, 13); + assert_eq!(pixels, picture(13)); + } + + #[test] + fn a_picture_overwritten_while_it_is_copied_is_dropped_whole() { + let mem = Vec8::new(layout().total_bytes()); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + writer.push_picture(&picture(1), 0, 0); + // While the reader copies slot 0 (reads 1 and 2 are its header and first row), the + // engine zeroes the slot's seq, as it does when it comes round to write it again. + let start = mem.reads.load(Ordering::SeqCst); + *mem.hook.lock().unwrap() = Some(Box::new(move |n, m| { + if n == start + 2 { + m.store_u64(64, 0); + } + })); + let mut pixels = Vec::new(); + assert_eq!(reader.next_picture(&mut pixels), None); + assert_eq!(reader.dropped(), 1); + // The next picture is fine. + *mem.hook.lock().unwrap() = None; + writer.push_picture(&picture(2), 0, 0); + assert_eq!(reader.next_picture(&mut pixels).unwrap().seq, 2); + assert_eq!(pixels, picture(2)); + } + + #[test] + fn a_slot_of_another_kind_is_skipped() { + let mem = Vec8::new(layout().total_bytes()); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + writer.push_picture(&picture(1), 0, 0); + mem.write(64 + SLOT_KIND, &1u32.to_le_bytes()); + let mut pixels = Vec::new(); + assert_eq!(reader.next_picture(&mut pixels), None); + assert_eq!(reader.dropped(), 1); + } + + #[test] + fn a_picture_of_the_wrong_size_is_not_written() { + let mem = Vec8::new(layout().total_bytes()); + RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + assert!(!writer.push_picture(&[0; 10], 0, 0)); + assert!(!writer.push_picture(&[0; 100], 0, 0)); + } + + #[test] + fn the_engine_checks_the_ring_against_what_it_was_asked() { + let l = layout(); + let mem = Vec8::new(l.total_bytes()); + RingReader::create(&mem, l).unwrap(); + assert!(RingWriter::open(&mem, 4, 2, l.total_bytes()).is_ok()); + assert_eq!( + RingWriter::open(&mem, 5, 2, 0).err(), + Some(RingError::Inconsistent) + ); + assert_eq!( + RingWriter::open(&mem, 4, 2, l.total_bytes() + 1).err(), + Some(RingError::TooShort) + ); + let short = Vec8::new(10); + assert_eq!( + RingWriter::open(&short, 4, 2, 0).err(), + Some(RingError::TooShort) + ); + } + + #[test] + fn sound_goes_through_the_audio_ring() { + let mem = Vec8::new(layout().total_bytes()); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + let mut out = Vec::new(); + // The first look starts at the present. + writer.push_audio(&[0.5, -0.5]); + assert_eq!(reader.read_audio(&mut out), AudioRead::default()); + assert!(out.is_empty()); + writer.push_audio(&[0.25, -0.25, 0.125, -0.125]); + assert_eq!( + reader.read_audio(&mut out), + AudioRead { frames: 2, lost: 0 } + ); + assert_eq!(out, [0.25, -0.25, 0.125, -0.125]); + assert_eq!(reader.read_audio(&mut out).frames, 0); + } + + #[test] + fn sound_survives_the_wrap_of_the_ring() { + let mem = Vec8::new(layout().total_bytes()); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + let mut out = Vec::new(); + reader.read_audio(&mut out); + let ring = AUDIO_FRAMES as usize; + // Fill up to 10 frames short of the end, read, then write across the end. + let first: Vec = (0..(ring - 10) * 2).map(|i| i as f32).collect(); + writer.push_audio(&first); + assert_eq!(reader.read_audio(&mut out).frames, ring - 10); + out.clear(); + let across: Vec = (0..40).map(|i| 1000.0 + i as f32).collect(); + writer.push_audio(&across); + assert_eq!(reader.read_audio(&mut out).frames, 20); + assert_eq!(out, across); + } + + #[test] + fn sound_the_engine_overwrote_is_counted_as_lost() { + let mem = Vec8::new(layout().total_bytes()); + let mut reader = RingReader::create(&mem, layout()).unwrap(); + let mut writer = RingWriter::open(&mem, 4, 2, 0).unwrap(); + let mut out = Vec::new(); + reader.read_audio(&mut out); + let ring = AUDIO_FRAMES as usize; + let total = ring + 100; + let sound: Vec = (0..total * 2).map(|i| i as f32).collect(); + writer.push_audio(&sound); + let got = reader.read_audio(&mut out); + assert_eq!( + got, + AudioRead { + frames: ring, + lost: 100 + } + ); + assert_eq!(out.len(), ring * 2); + assert_eq!(out[0], (100 * 2) as f32); + assert_eq!(*out.last().unwrap(), (total * 2 - 1) as f32); + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index be5f131..3a85684 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -51,7 +51,7 @@ A separate process that the launcher starts and watches ([ADR 0004](adr/0004-net - **Sound devices** sit behind the `devices` feature of `anyplay-net` (cpal). Release builds on Windows and Linux are built with `--features devices`; on Linux that needs the ALSA headers (`libasound2-dev`). Without the feature, which is what `cargo build` and the usual CI jobs use, the service builds and runs and voice answers that this build has no sound devices. A CI job builds and lints the feature on both systems. - The launcher gets a `voice` event with the state, who is speaking and each link, at most ten a second, and sets push-to-talk, mode, volume, mute and the devices with the commands in the [control protocol](../spec/control-protocol.md). The launcher never sees tunnel keys. - **Share screen** ([spec](../spec/screen-share.md), [ADR 0023](adr/0023-share-screen-is-watch-only-and-asks-the-cloud-for-nothing.md)). The watch-only part of share play: a player streams their game to the party, whose members choose to watch. A connection with the ALPN `anyplay/screen/0`, dialed by the streamer to each party member, carries share play's datagrams; no cloud state, no `share.enabled`. Built, with game sound on Windows; the share worker also publishes the picture live over RTMP for broadcasts. -- **Share play** ([spec](../spec/share-play.md), [ADR 0019](adr/0019-share-play-streams-through-a-worker.md); designed, only the part that share screen uses is built). A third connection on the tunnels' endpoint, with the ALPN `anyplay/share/0`, carries a game's picture and sound to one party member and that member's pad states back. A separate share worker encodes and decodes; the net service checks who connects, validates and rate-limits pad states, and forwards datagrams. +- **Share play** ([spec](../spec/share-play.md), [ADR 0019](adr/0019-share-play-streams-through-a-worker.md); designed; built so far: the part that share screen uses, the bridge's `0x05xx` kinds with the frame ring and the guest pad on both sides of the bridge, and the host's pad rules. Not built: the invitation, the cloud's `Share` and the share connection). A third connection on the tunnels' endpoint, with the ALPN `anyplay/share/0`, carries a game's picture and sound to one party member and that member's pad states back. A separate share worker encodes and decodes; the net service checks who connects, validates and rate-limits pad states, and forwards datagrams. - **Version guard** before every join. ### Cloud ([anyplaynet/cloud](https://github.com/anyplaynet/cloud)) diff --git a/docs/share-play-plan.md b/docs/share-play-plan.md index 5d2df49..037630f 100644 --- a/docs/share-play-plan.md +++ b/docs/share-play-plan.md @@ -38,6 +38,8 @@ Share screen ([ADR 0023](adr/0023-share-screen-is-watch-only-and-asks-the-cloud- **Decision gate.** If criterion 4 fails with datagrams, switch to the stream-per-frame design and amend the ADR before phase 1. +**Status (27.10.2026).** Built and tested without a game: the bridge kinds `0x0501` to `0x0506` in `crates/bridge-protocol`; the frame ring (`anyplay_share::ring`, the share worker's `shm` and `--source ring`); the guest's pad state, datagram, sender and the host's gate (`anyplay_share::pad`); `anyplay_net::shareplay::ShareHost`, the host's side towards the game, with a fake game that follows the engine's rules; the viewer's keyboard pad. In the AnyPS5 fork: the bridge kinds, the ring writer, the guest pad in the pad layer, the override chord and the frame sink (a separate pull request). Not built: the invitation and the cloud's `Share`, the share connection (`anyplay/share/0`) that carries the datagrams, the launcher's share window, and the engine-side proof with a real title. + ## Phase 1: watch only **Goal.** A friend without the game watches a game running in AnyPS5. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 0176ab7..ba13490 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -76,6 +76,13 @@ test = false doc = false bench = false +[[bin]] +name = "share_pad_datagram" +path = "fuzz_targets/share_pad_datagram.rs" +test = false +doc = false +bench = false + [[bin]] name = "share_control_line" path = "fuzz_targets/share_control_line.rs" diff --git a/fuzz/README.md b/fuzz/README.md index ddd0935..8a3e8ab 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -11,6 +11,7 @@ Coverage-guided fuzzing with [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuz | `control_line` | one line between the launcher and the net service | text that is not UTF-8 is refused; a command or output read is written back so that it reads the same | | `share_video_datagram` | a video datagram of share play from another member | read and written back to the same bytes, or refused for exactly the reason its length, version, type, flags or group layout give | | `share_audio_datagram` | an audio datagram of share play | read and written back to the same bytes, or refused for exactly its reason | +| `share_pad_datagram` | a pad datagram of share play from the guest, and a run of them through the host's gate | read and written back to the same bytes, or refused; no state with a reserved button bit gets through, none in `watch`, and no more than 30 in a burst and 300 a second | | `share_reassembler` | arbitrary datagrams with time passing; or a packetised frame with up to its parity of packets dropped | never more than 4 frames and 16 MiB in reassembly; a recoverable frame comes back byte for byte | | `share_control_line` | one line of share play's control stream | over 4 KiB, a second line or non-UTF-8 is refused; a message read is written back so that it reads the same | diff --git a/fuzz/fuzz_targets/share_pad_datagram.rs b/fuzz/fuzz_targets/share_pad_datagram.rs new file mode 100644 index 0000000..bbb3c70 --- /dev/null +++ b/fuzz/fuzz_targets/share_pad_datagram.rs @@ -0,0 +1,43 @@ +//! A pad datagram of share play from the guest, through the host's gate: read and written back +//! to the same bytes, or refused; and whatever the gate lets through is a state with no reserved +//! button bit, in an order that only goes forward, within the rate. + +#![no_main] + +use anyplay_share::pad::{BUTTON_MASK, PadDatagram, PadGate, PadMode, PadRefusal, TYPE_PAD}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + match PadDatagram::parse(data) { + Ok(d) => { + assert_eq!(d.encode().unwrap(), data); + assert!((1..=3).contains(&d.states.len())); + assert!(d.states.iter().all(|s| s.buttons & !BUTTON_MASK == 0)); + } + Err(PadRefusal::Type(t)) => assert!(data[1] == t && t != TYPE_PAD), + Err(_) => {} + } + + // The first byte picks the mode, the rest is fed to a gate as datagrams cut at 0xFF bytes. + let Some((mode, rest)) = data.split_first() else { + return; + }; + let mut gate = PadGate::new(0); + gate.set_mode(match mode % 3 { + 0 => PadMode::Watch, + 1 => PadMode::HandOver, + _ => PadMode::Together, + }); + let mut applied = 0u64; + for (i, piece) in rest.split(|b| *b == 0xFF).enumerate() { + let out = gate.on_datagram(i as u64 * 10_000, piece); + for (_, state) in &out.apply { + assert!(state.buttons & !BUTTON_MASK == 0); + assert!(gate.mode() != PadMode::Watch); + } + applied += out.apply.len() as u64; + } + // 30 in a burst and 300 a second: no more than that can come out of this many datagrams. + let seconds = rest.split(|b| *b == 0xFF).count() as u64 / 100; + assert!(applied <= 30 + 300 * (seconds + 1) + 3); +}); diff --git a/spec/bridge-protocol.md b/spec/bridge-protocol.md index 22f9ff7..a04c24f 100644 --- a/spec/bridge-protocol.md +++ b/spec/bridge-protocol.md @@ -201,7 +201,23 @@ The first user is Minecraft: Bedrock Edition in its LAN mode. What public source ### Share play (`0x05xx`) -Drafted in the [share play spec](share-play.md#bridge-protocol-additions): opening and closing the frame ring, the share mode, the guest's pad states, and the host's override. Not implemented. +Version 0, implemented in `crates/bridge-protocol` (the net service's side) and in the AnyPS5 bridge. The frame ring, the pad rules and the host's override are in the [share play spec](share-play.md); this is the wire. + +| Kind | Name | Direction | Payload | +|---|---|---|---| +| `0x0501` | share open | service → bridge, request | `ring: str`, `ring_bytes: u32`, `width: u32`, `height: u32`, `max_fps: u32`, `mode: u8` | +| `0x8501` | share opened | bridge → service | `result: u8` | +| `0x0502` | share close | service → bridge | empty | +| `0x0503` | share mode | service → bridge | `mode: u8` | +| `0x0504` | share pad | service → bridge | `slot: u8`, `sequence: u16`, `buttons: u32`, `lx: u8`, `ly: u8`, `rx: u8`, `ry: u8`, `l2: u8`, `r2: u8` | +| `0x0505` | override | bridge → service | `action: u8` | +| `0x0506` | share status | bridge → service | `frames: u32`, `dropped: u32`, `audio_underruns: u32` | + +- `mode` is 0 `watch`, 1 `hand_over`, 2 `together`. `slot` is 0 for pad 1 and 1 for pad 2. `result` is 0 (mapped, copying), 1 (the ring cannot be opened), 2 (a share is running already), 3 (the game does not present through the tap). `action` is 1 (the host took control back: the mode is now `watch`) or 2 (the host asked to stop). `buttons` has the bits of [the pad state](share-play.md#pad-state). +- `ring` is a name made of `anyplay-` and letters, digits, `-` and `_`, 30 characters at most (macOS limits a `shm_open` name to 31 bytes with its slash). The bridge opens it as it is, with no namespace prefix (a named file mapping on Windows, `shm_open("/" + name)` elsewhere), and answers 1 to anything else. The ring is made by the share worker and never by the engine; the bridge answers 1 when the mapping is shorter than `ring_bytes` or than the layout its header gives, when the header is wrong, or when its width or height is not the one asked for. Both sizes are even. +- The bridge takes a `share pad` only after a `share open` answered 0, only while the last mode is `hand_over` (slot 0) or `together` (slot 1), and drops a state that sets a bit above bit 14. Whenever the mode changes, the share closes or the connection to the service breaks, the guest's pad is neutral and disconnected in the same step. The service sends a neutral `share pad` on the old slot before every change of mode and before the close, so a bridge that missed nothing sees the release first. +- `sequence` counts the guest's states, wrapping; the service has ordered them already and the bridge need not check it. +- `share status` is sent once a second while the ring is in use. The service sends nothing of this group before the bridge has answered a `share open` with 0, except `share close`. ## Observation diff --git a/spec/share-play.md b/spec/share-play.md index 1497b83..ecfa087 100644 --- a/spec/share-play.md +++ b/spec/share-play.md @@ -1,6 +1,6 @@ # Share play -How a player streams the game they are running to a friend in their party, who watches, takes over their pad, or plays beside them. Version 0, a draft: it changes without notice until version 1. Nothing here is built yet, except the part that [share screen](screen-share.md) uses: a watch-only stream to the whole party that needs no invitation and no cloud record ([ADR 0023](../docs/adr/0023-share-screen-is-watch-only-and-asks-the-cloud-for-nothing.md)). +How a player streams the game they are running to a friend in their party, who watches, takes over their pad, or plays beside them. Version 0, a draft: it changes without notice until version 1. Built so far: the part that [share screen](screen-share.md) uses (a watch-only stream to the whole party that needs no invitation and no cloud record, [ADR 0023](../docs/adr/0023-share-screen-is-watch-only-and-asks-the-cloud-for-nothing.md)), and the game's side of share play: the [bridge kinds](bridge-protocol.md#share-play-0x05xx), the [frame ring](#frame-ring), the [pad state](#pad-state) with the host's rules, and the host's [override](#the-hosts-override). Not built: the invitation, the cloud's `Share`, and the share connection that carries the stream and the guest's pad between two PCs. The decisions are in [ADR 0019](../docs/adr/0019-share-play-streams-through-a-worker.md), the order of work in the [plan](../docs/share-play-plan.md), and the prior art with its sources in the [research notes](../docs/share-play-research.md). The terms are in [CONTEXT.md](../CONTEXT.md). Share play goes through the tunnels like voice ([ADR 0010](../docs/adr/0010-voice-and-shared-play-go-through-the-tunnels.md), [ADR 0017](../docs/adr/0017-voice-has-a-connection-of-its-own.md), [voice spec](voice.md)). @@ -138,13 +138,13 @@ Created by the worker; the engine maps it when the bridge tells it the name. One | 24 | 4 | stride in bytes | | 28 | 4 | format: 1 BGRA8 | | 32 | 4 | maximum frames per second | -| 36 | 4 | audio ring size in frames, a power of two | +| 36 | 4 | audio ring size in frames, a power of two (16,384 in version 0) | | 40 | 8 | audio write position, in frames, written by the engine | | 48 | 16 | reserved, zero | -After the header come the slots, each with a 32-byte slot header and `stride × height` bytes, then the audio ring. A slot header holds `seq: u64` (written last, 0 while the engine writes), `present_us: u64` (the engine's monotonic clock when the picture was presented), `kind: u32` (0 for pixels in the slot; 1 reserved for a GPU handle) and `frame: u32` (the game's flip count). The consumer reads the slot with the highest `seq` it has not seen, copies or uses it, and reads `seq` again; if it changed, the slot was overwritten and the consumer drops the frame. The audio ring is a plain single-producer ring of stereo float frames. +The width and the height are even, and at most 3840 by 2160, so that every slot starts on a multiple of 8 bytes, where its `seq` is read atomically. The header is 64 bytes. After the header come the slots, each with a 32-byte slot header and `stride × height` bytes, then the audio ring. A slot header holds `seq: u64` (written last, 0 while the engine writes), `present_us: u64` (the engine's monotonic clock when the picture was presented), `kind: u32` (0 for pixels in the slot; 1 reserved for a GPU handle) and `frame: u32` (the game's flip count). The consumer reads the slot with the highest `seq` it has not seen, copies or uses it, and reads `seq` again; if it changed, the slot was overwritten and the consumer drops the frame. The audio ring is a plain single-producer ring of stereo float frames. -The name is random for each share and the mapping is readable only by the user who runs the game. On Windows it is a named file mapping in the local namespace; on Linux a `shm_open` object. Nothing else is shared: pad states do not use the ring. +The name is random for each share (`anyplay-` and 16 hex digits, 24 characters, so that it fits macOS's 31-byte limit for a shared memory name) and the mapping is readable only by the user who runs the game; the engine opens it without a namespace prefix ([bridge protocol](bridge-protocol.md#share-play-0x05xx)). On Windows it is a named file mapping in the local namespace; on Linux a `shm_open` object. Nothing else is shared: pad states do not use the ring. ### Codecs @@ -395,9 +395,10 @@ Between the net service and the worker, on `127.0.0.1`, with the framing of the | `0x0014` | datagram out | worker → service | one datagram to send, with its priority | | `0x0015` | datagram in | service → worker | one datagram received | | `0x0016` | report | worker → service | what the controller needs (guest) | -| `0x0017` | pad | worker → service | one pad state (guest) | +| `0x0017` | pad | worker → service | one pad state (guest): 10 bytes, `buttons` as a big-endian `u32`, then `lx`, `ly`, `rx`, `ry`, `l2`, `r2` | | `0x0018` | stats | worker → service | encode time, decode time, frames shown, dropped | | `0x0019` | stop | either | the end | +| `0x001C` | ring ready | worker → service | `name`, `bytes`, `width`, `height`, `max_fps`: the worker made the frame ring (host) | | `0x001A` | key request | worker → service | the viewer needs a key frame from this frame on (`epoch`, `frame`); the service puts it on the control stream as `key` | | `0x001B` | ended | worker → service | the worker is ending, with a reason in words (the picture source ended, the encoder failed, the window was closed) |