diff --git a/.github/workflows/all.yml b/.github/workflows/all.yml index 48047b96..c8a5c0bf 100644 --- a/.github/workflows/all.yml +++ b/.github/workflows/all.yml @@ -15,8 +15,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: tombi-toml/setup-tombi@9fdb1f12707aafdc140839c60421336d4ae157ea + - uses: tombi-toml/setup-tombi@23af0f0e4fcad609f048a37fb312714cd6b07741 with: - version: 1.2.6 + version: 1.4.0 - run: tombi lint - run: tombi fmt --check diff --git a/AGENTS.md b/AGENTS.md index 20a5b198..d5331525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ See `README.md` for an overview of this Rust crate. # Checking -When checking code, use `cargo clippy --workspace --all-features --all-targets` instead of `cargo check`. +Use `just prepare` to apply any automatic fixes like typos, formatting. Use `just check` to run the same checks as CI. Always use `cargo clippy` instead of `cargo check`. # Updating diff --git a/Cargo.toml b/Cargo.toml index 7bde3214..1108d07e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,40 +11,14 @@ keywords = ["bevy", "gamedev", "network"] categories = ["game-development", "network-programming"] [workspace.dependencies] -aeronet = { - path = "crates/aeronet", - version = "0.21.0", - default-features = false -} +aeronet = { path = "crates/aeronet", version = "0.21.0", default-features = false } aeronet_channel = { path = "crates/aeronet_channel", version = "0.21.0" } -aeronet_io = { - path = "crates/aeronet_io", - version = "0.21.0", - default-features = false -} -aeronet_replicon = { - path = "crates/aeronet_replicon", - version = "0.21.0", - default-features = false -} -aeronet_tokio_runtime = { - path = "crates/aeronet_tokio_runtime", - version = "0.21.0" -} -aeronet_transport = { - path = "crates/aeronet_transport", - version = "0.21.0", - default-features = false -} -aeronet_websocket = { - path = "crates/aeronet_websocket", - version = "0.21.0", - default-features = false -} -aeronet_webtransport = { - path = "crates/aeronet_webtransport", - version = "0.21.0" -} +aeronet_io = { path = "crates/aeronet_io", version = "0.21.0", default-features = false } +aeronet_replicon = { path = "crates/aeronet_replicon", version = "0.21.0", default-features = false } +aeronet_tokio_runtime = { path = "crates/aeronet_tokio_runtime", version = "0.21.0" } +aeronet_transport = { path = "crates/aeronet_transport", version = "0.21.0", default-features = false } +aeronet_websocket = { path = "crates/aeronet_websocket", version = "0.21.0", default-features = false } +aeronet_webtransport = { path = "crates/aeronet_webtransport", version = "0.21.0" } anyhow = { version = "1.0.97", default-features = false } arbitrary = { version = "1.3.2", features = ["derive"] } base64 = { version = "0.22.1" } @@ -78,11 +52,7 @@ octs = { version = "1.0.0", default-features = false } oneshot = { version = "0.1.11" } rcgen = { version = "0.13.1" } ringbuf = { version = "0.4.8", default-features = false } -rustls = { - version = "0.23.23", - default-features = false, - features = ["logging", "std", "tls12"] -} +rustls = { version = "0.23.23", default-features = false, features = ["logging", "std", "tls12"] } rustls-native-certs = { version = "0.8.0" } serde = { version = "1.0.219", features = ["derive"] } size_format = { version = "1.0.2" } @@ -120,7 +90,4 @@ unwrap_used = "warn" [workspace.lints.rust] missing_docs = "warn" -unexpected_cfgs = { - level = "warn", - check-cfg = ["cfg(fuzzing)", "cfg(docsrs_aeronet)"] -} +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)", "cfg(docsrs_aeronet)"] } diff --git a/README.md b/README.md index 890613af..bd4daf42 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ cargo run --example iroh_peer -- --remote ENDPOINT Uses Steam's [networking sockets](https://partner.steamgames.com/doc/api/ISteamnetworkingSockets) -You will need Steam running locally on your machine to be able to run the examples. If you want to test out peer-to-peer connections (not socket address connections), you will need to run two separate Steam clients using two separate Steam accounts - see *Development Environment* for an easy way to achieve this. +You will need Steam running locally on your machine to be able to run the examples. If you want to test out peer-to-peer connections (not socket address connections), you will need to run two separate Steam clients using two separate Steam accounts - see *Development Environment* for an easy way to achieve this. ```sh # run a server which listens on a socket address diff --git a/crates/aeronet/docs/changelog.md b/crates/aeronet/docs/changelog.md index cee06062..e449fa6b 100644 --- a/crates/aeronet/docs/changelog.md +++ b/crates/aeronet/docs/changelog.md @@ -6,6 +6,7 @@ Version changelog. - Changed `SessionRequest` to be an entity event triggered on the server when it receives a new session request, instead of a global event (requiring a global observer) - Made `aeronet_websocket`'s rustls crypto provider selectable while keeping AWS-LC enabled by default - `aeronet_websocket` no longer installs a crypto provider when its plugins are added; applications must install their selected provider themselves when both `ring` and `aws-lc-rs` are enabled (or when the default `aws-lc-rs` feature is disabled), otherwise TLS configuration will panic +- Allow using dedicated Steam server sockets for `aeronet_steam` # 0.21.0 diff --git a/crates/aeronet_steam/Cargo.toml b/crates/aeronet_steam/Cargo.toml index 3dc57935..74805c92 100644 --- a/crates/aeronet_steam/Cargo.toml +++ b/crates/aeronet_steam/Cargo.toml @@ -23,6 +23,11 @@ name = "steam_server" path = "examples/steam_server.rs" required-features = ["server"] +[[example]] +name = "steam_dedicated_server" +path = "examples/steam_dedicated_server.rs" +required-features = ["server"] + [dependencies] aeronet_io = { workspace = true } anyhow = { workspace = true } diff --git a/crates/aeronet_steam/examples/steam_client.rs b/crates/aeronet_steam/examples/steam_client.rs index 55b6cb6c..c379238a 100644 --- a/crates/aeronet_steam/examples/steam_client.rs +++ b/crates/aeronet_steam/examples/steam_client.rs @@ -14,7 +14,7 @@ use { connection::{Disconnect, DisconnectReason, Disconnected}, }, aeronet_steam::{ - SessionConfig, SteamworksClient, + SessionConfig, SteamworksSockets, client::{SteamNetClient, SteamNetClientPlugin}, }, bevy::prelude::*, @@ -28,8 +28,8 @@ fn main() -> AppExit { steam.networking_utils().init_relay_network_access(); App::new() - .insert_resource(SteamworksClient(steam)) - .add_systems(PreUpdate, |steam: Res| { + .insert_resource(SteamworksSockets::Client(steam)) + .add_systems(PreUpdate, |steam: Res| { steam.run_callbacks(); }) .add_plugins((DefaultPlugins, EguiPlugin::default(), SteamNetClientPlugin)) diff --git a/crates/aeronet_steam/examples/steam_dedicated_server.rs b/crates/aeronet_steam/examples/steam_dedicated_server.rs new file mode 100644 index 00000000..96276f70 --- /dev/null +++ b/crates/aeronet_steam/examples/steam_dedicated_server.rs @@ -0,0 +1,189 @@ +//! Example dedicated server using Steam, which listens for clients sending +//! strings and sends back a string reply. +//! +//! Unlike [`steam_server`], this example logs on to Steam as a dedicated game +//! server, so it must be run with a Steam app ID that is configured for +//! dedicated server hosting. The app ID is provided programmatically below by +//! setting the `SteamAppId`/`SteamGameId` env vars (the same trick +//! [`steamworks::Client::init_app`] uses) instead of relying on a +//! `steam_appid.txt` file. +//! +//! The IO layer is identical to the regular server: the only difference is +//! that the [`SteamworksSockets`] resource wraps a [`steamworks::Server`] +//! instead of a [`steamworks::Client`]. +//! +//! [`steam_server`]: ./steam_server.rs + +cfg_if::cfg_if! { + if #[cfg(target_family = "wasm")] { + fn main() { + panic!("not supported on WASM"); + } + } else { + +use { + aeronet_io::{ + Session, SessionEndpoint, + connection::{DisconnectReason, Disconnected, LocalAddr}, + server::{Closed, Server}, + }, + aeronet_steam::{ + SessionConfig, SteamworksSockets, + server::{ + ListenTarget, SessionRequest, SessionResponse, SteamNetServer, SteamNetServerPlugin, + }, + }, + bevy::{log::LogPlugin, prelude::*}, + core::net::{Ipv4Addr, SocketAddr}, + std::env, +}; + +fn main() -> AppExit { + // `steamworks::Server::init` has no `init_app` helper, but it determines the + // app ID from the `SteamAppId`/`SteamGameId` env vars, falling back to a + // `steam_appid.txt` in the working dir only if both are unset. Set them here + // (Spacewar, 480) so the example works without any `steam_appid.txt` file. + // SAFETY: single-threaded before process startup; setting these env vars + // mirrors exactly what `steamworks::Client::init_app` does. + unsafe { + std::env::set_var("SteamAppId", "480"); + std::env::set_var("SteamGameId", "480"); + } + + let (server, server_callbacks) = steamworks::Server::init( + Ipv4Addr::LOCALHOST, + 25572, + 27016, + steamworks::ServerMode::AuthenticationAndSecure, + "1.0.0.0", + ) + .expect("failed to initialize dedicated server"); + + server.set_game_description("Description"); + server.set_mod_dir("spacewar"); + server.set_product("spacewar"); + server.set_map_name("island"); + server.set_max_players(16); + server.set_server_name("Aeronet server"); + server.set_dedicated_server(true); + server.log_on_anonymous(); + server.enable_heartbeats(true); + + server_callbacks.networking_utils().init_relay_network_access(); + + let steam_id = server.steam_id(); + info!("dedicated server steam ID: {steam_id:?}"); + + // Note: `Server::init` also returns a `steamworks::Client` that shares the + // same underlying handle, so running callbacks on the `Server` covers the + // same callback pump. We only need to insert the `SteamworksSockets` + // resource, which handles both the IO layer and running callbacks. + App::new() + .insert_resource(SteamworksSockets::Server(server)) + .add_systems(PreUpdate, |steam: Res| { + steam.run_callbacks(); + }) + .add_plugins((MinimalPlugins, LogPlugin::default(), SteamNetServerPlugin)) + .add_systems(Startup, open_server) + .add_systems(Update, reply) + .add_observer(on_opened) + .add_observer(on_closed) + .add_observer(on_session_request) + .add_observer(on_connecting) + .add_observer(on_connected) + .add_observer(on_disconnected) + .run() +} + +fn open_server(mut commands: Commands) { + let target = match env::args().nth(1).as_deref() { + Some("addr") => ListenTarget::Addr(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 27015)), + Some("peer") => ListenTarget::Peer { virtual_port: 0 }, + _ => panic!("must specify either `addr` or `peer` argument on command line"), + }; + + commands + .spawn_empty() + .queue(SteamNetServer::open(SessionConfig::default(), target)); +} + +fn on_opened(trigger: On, servers: Query<&LocalAddr>) { + let server = trigger.event_target(); + if let Ok(local_addr) = servers.get(server) { + info!("{server} opened on {:?}", **local_addr); + } else { + info!("{server} opened for peer connections"); + } +} + +fn on_closed(trigger: On) { + panic!("server closed: {:?}", trigger.event()); +} + +fn on_session_request(mut request: On, clients: Query<&ChildOf>) { + let client = request.event_target(); + let Ok(&ChildOf(server)) = clients.get(client) else { + return; + }; + + info!( + "{client} connecting to {server} with Steam ID {:?}", + request.steam_id + ); + request.respond(SessionResponse::Accepted); +} + +fn on_connecting(trigger: On, clients: Query<&ChildOf>) { + let client = trigger.event_target(); + let Ok(&ChildOf(server)) = clients.get(client) else { + return; + }; + + info!("{client} connecting to {server}"); +} + +fn on_connected(trigger: On, clients: Query<&ChildOf>) { + let client = trigger.event_target(); + let Ok(&ChildOf(server)) = clients.get(client) else { + return; + }; + + info!("{client} connected to {server}"); +} + +fn on_disconnected(trigger: On, clients: Query<&ChildOf>) { + let client = trigger.event_target(); + let Ok(&ChildOf(server)) = clients.get(client) else { + return; + }; + + match &trigger.reason { + DisconnectReason::ByUser(reason) => { + info!("{client} disconnected from {server} by user: {reason}"); + } + DisconnectReason::ByPeer(reason) => { + info!("{client} disconnected from {server} by peer: {reason}"); + } + DisconnectReason::ByError(err) => { + warn!("{client} disconnected from {server} due to error: {err:#}"); + } + } +} + +fn reply(mut clients: Query<(Entity, &mut Session), With>) { + for (client, mut session) in &mut clients { + // explicit deref so we can access disjoint fields + let session = &mut *session; + for packet in session.recv.drain(..) { + let msg = + String::from_utf8(packet.payload.into()).unwrap_or_else(|_| "(not UTF-8)".into()); + info!("{client} > {msg}"); + + let reply = format!("You sent: {msg}"); + info!("{client} < {reply}"); + session.send.push(reply.into()); + } + } +} + +}} diff --git a/crates/aeronet_steam/examples/steam_server.rs b/crates/aeronet_steam/examples/steam_server.rs index 9613b1a3..2267d967 100644 --- a/crates/aeronet_steam/examples/steam_server.rs +++ b/crates/aeronet_steam/examples/steam_server.rs @@ -15,7 +15,7 @@ use { server::{Closed, Server}, }, aeronet_steam::{ - SessionConfig, SteamworksClient, + SessionConfig, SteamworksSockets, server::{ ListenTarget, SessionRequest, SessionResponse, SteamNetServer, SteamNetServerPlugin, }, @@ -30,8 +30,8 @@ fn main() -> AppExit { steam.networking_utils().init_relay_network_access(); App::new() - .insert_resource(SteamworksClient(steam)) - .add_systems(PreUpdate, |steam: Res| { + .insert_resource(SteamworksSockets::Client(steam)) + .add_systems(PreUpdate, |steam: Res| { steam.run_callbacks(); }) .add_plugins((MinimalPlugins, LogPlugin::default(), SteamNetServerPlugin)) diff --git a/crates/aeronet_steam/src/client.rs b/crates/aeronet_steam/src/client.rs index 3b7c0365..b93582ad 100644 --- a/crates/aeronet_steam/src/client.rs +++ b/crates/aeronet_steam/src/client.rs @@ -2,7 +2,7 @@ use { crate::{ - SteamworksClient, + SteamworksSockets, config::SessionConfig, session::{SessionError, SteamNetIo, SteamNetSessionPlugin, entity_to_user_data}, }, @@ -82,8 +82,8 @@ impl SteamNetClient { /// Creates an [`EntityCommand`] to set up a session and connect it to the /// `target`. /// - /// [`SteamworksClient`] must be present in the world before this command is - /// applied. + /// [`SteamworksSockets`] must be present in the world before this command + /// is applied. /// /// # Examples /// @@ -120,7 +120,7 @@ fn connect(mut entity: EntityWorldMut, config: SessionConfig, target: ConnectTar let mtu = config.send_buffer_size; let sockets = entity .world() - .resource::() + .resource::() .networking_sockets(); let (tx_next, rx_next) = oneshot::channel::(); blocking::unblock(move || { diff --git a/crates/aeronet_steam/src/lib.rs b/crates/aeronet_steam/src/lib.rs index 9a4abc90..a28065ec 100644 --- a/crates/aeronet_steam/src/lib.rs +++ b/crates/aeronet_steam/src/lib.rs @@ -3,10 +3,7 @@ #![cfg(not(target_family = "wasm"))] pub use steamworks; -use { - bevy_ecs::prelude::*, - derive_more::{Deref, DerefMut}, -}; +use {bevy_ecs::prelude::*, steamworks::networking_sockets}; #[cfg(feature = "client")] pub mod client; @@ -17,9 +14,46 @@ pub mod session; mod config; pub use config::SessionConfig; -/// [`steamworks::Client`] used to drive Steam networking socket IO. +/// [`steamworks::Client`] or [`steamworks::Server`] instance used to drive +/// Steam networking socket IO. /// -/// You must initialize a [`steamworks::Client`] yourself, then insert this -/// resource into the app manually. -#[derive(Deref, Clone, DerefMut, Resource)] -pub struct SteamworksClient(pub steamworks::Client); +/// This is used by both clients and servers to obtain the [`NetworkingSockets`] +/// that they drive their IO over, and to run Steam callbacks. You must +/// initialize the relevant [`steamworks::Client`] or [`steamworks::Server`] +/// yourself, wrap it in this enum, and insert it into the app as a resource. +/// +/// [`NetworkingSockets`]: networking_sockets::NetworkingSockets +#[derive(Clone, Resource)] +pub enum SteamworksSockets { + /// A [`steamworks::Client`] driving socket IO. + Client(steamworks::Client), + /// A [`steamworks::Server`] driving socket IO. + Server(steamworks::Server), +} + +impl SteamworksSockets { + /// Gets the [`NetworkingSockets`] that this wrapper drives IO over. + /// + /// [`NetworkingSockets`]: networking_sockets::NetworkingSockets + #[must_use] + pub fn networking_sockets(&self) -> networking_sockets::NetworkingSockets { + match self { + Self::Client(steamworks_client) => steamworks_client.networking_sockets(), + Self::Server(steamworks_server) => steamworks_server.networking_sockets(), + } + } + + /// Runs any currently pending Steam callbacks. + /// + /// This should be called frequently (e.g. once per frame) so that Steam + /// networking events are delivered to the IO layer. + /// + /// See [`steamworks::Client::run_callbacks`] and + /// [`steamworks::Server::run_callbacks`]. + pub fn run_callbacks(&self) { + match self { + Self::Client(steamworks_client) => steamworks_client.run_callbacks(), + Self::Server(steamworks_server) => steamworks_server.run_callbacks(), + } + } +} diff --git a/crates/aeronet_steam/src/server.rs b/crates/aeronet_steam/src/server.rs index 8e962ca7..dc0ebb48 100644 --- a/crates/aeronet_steam/src/server.rs +++ b/crates/aeronet_steam/src/server.rs @@ -2,7 +2,7 @@ use { crate::{ - SteamworksClient, + SteamworksSockets, config::SessionConfig, session::{SessionError, SteamNetIo, SteamNetSessionPlugin, entity_to_user_data}, }, @@ -118,8 +118,8 @@ impl SteamNetServer { /// Creates an [`EntityCommand`] to set up a server and have it start /// listening for connections. /// - /// [`SteamworksClient`] must be present in the world before this command is - /// applied. + /// [`SteamworksSockets`] must be present in the world before this command + /// is applied. /// /// # Examples /// @@ -156,7 +156,7 @@ fn open(mut entity: EntityWorldMut, config: SessionConfig, target: ListenTarget) let mtu = config.send_buffer_size; let sockets = entity .world() - .resource::() + .resource::() .networking_sockets(); let (tx_next, rx_next) = oneshot::channel::(); blocking::unblock(move || { @@ -245,14 +245,17 @@ impl SessionResponse { /// ``` /// use { /// aeronet_steam::{ -/// SteamworksClient, +/// SteamworksSockets, /// server::{SessionRequest, SessionResponse}, /// }, /// bevy_ecs::prelude::*, /// steamworks::FriendFlags, /// }; /// -/// fn on_session_request(mut request: On, steam: Res) { +/// fn on_session_request(mut request: On, sockets: Res) { +/// let SteamworksSockets::Client(steam) = sockets.as_ref() else { +/// return; +/// }; /// let friend = steam.friends().get_friend(request.steam_id); /// if !friend.has_friend(FriendFlags::IMMEDIATE) { /// request.respond(SessionResponse::rejected("not friend of the host")); diff --git a/crates/aeronet_steam/src/session.rs b/crates/aeronet_steam/src/session.rs index 5d3ff4fd..a4a23ff0 100644 --- a/crates/aeronet_steam/src/session.rs +++ b/crates/aeronet_steam/src/session.rs @@ -2,7 +2,7 @@ //! servers. use { - crate::SteamworksClient, + crate::SteamworksSockets, aeronet_io::{ AeronetIoPlugin, IoSystems, Session, connection::{DisconnectReason, Disconnected, UNKNOWN_DISCONNECT_REASON}, @@ -87,7 +87,7 @@ struct PollGroup(NetPollGroup); fn init_io( trigger: On, - steam: Option>, + steam: Option>, io: Query<&SteamNetIo>, poll_group: Option>, mut commands: Commands, @@ -95,7 +95,7 @@ fn init_io( let steam = steam.unwrap_or_else(|| { panic!( "`{}` must be present before creating a Steam IO layer", - type_name::>() + type_name::>() ) }); @@ -129,7 +129,7 @@ fn init_io( fn poll_io( mut commands: Commands, sessions: Query<(Entity, &SteamNetIo)>, - steam: Res, + steam: Res, ) { let sockets = steam.networking_sockets(); for (entity, io) in &sessions { diff --git a/examples/Cargo.toml b/examples/Cargo.toml index d597d359..61c39db8 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -11,10 +11,7 @@ publish = false [dependencies] aeronet = { workspace = true, features = ["visualizer"] } aeronet_replicon = { workspace = true, features = ["client", "server"] } -aeronet_websocket = { - workspace = true, - features = ["client", "self-signed", "server"] -} +aeronet_websocket = { workspace = true, features = ["client", "self-signed", "server"] } aeronet_webtransport = { workspace = true, features = [ diff --git a/justfile b/justfile index cdd10ebc..6f173ae6 100644 --- a/justfile +++ b/justfile @@ -31,4 +31,4 @@ prepare: tombi fmt cargo +nightly fmt cargo shear --fix - cargo clippy --fix + cargo clippy --fix --allow-dirty