Split SDK and Dispatch into different servers

This commit is contained in:
xeon 2024-04-16 00:00:57 +03:00
parent 9624a07ad2
commit da466f961b
26 changed files with 363 additions and 191 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
target/ target/
Cargo.lock Cargo.lock
proto/StarRail.proto proto/StarRail.proto
/*.json

View file

@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["common", "gameserver", "proto", "sdkserver", "xtask"] members = ["common", "dispatch", "gameserver", "proto", "sdkserver", "xtask"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
@ -15,6 +15,8 @@ axum = "0.7.4"
axum-server = "0.6.0" axum-server = "0.6.0"
tower = "0.4.13" tower = "0.4.13"
tower-http = { version = "0.5.2", features = ["normalize-path"] } tower-http = { version = "0.5.2", features = ["normalize-path"] }
hyper = { version = "1.3.0", features = [ "client" ] }
hyper-util = { version = "0.1.3", features = [ "client-legacy" ] }
dirs = "5.0.1" dirs = "5.0.1"
dotenv = "0.15.0" dotenv = "0.15.0"

View file

@ -18,8 +18,9 @@ A Server emulator for the game [`Honkai: Star Rail`](https://hsr.hoyoverse.com/e
```sh ```sh
git clone https://git.xeondev.com/reversedrooms/AcheronSR.git git clone https://git.xeondev.com/reversedrooms/AcheronSR.git
cd AcheronSR cd AcheronSR
cargo install --path gameserver cargo build --bin gameserver
cargo install --path sdkserver cargo build --bin dispatch
cargo build --bin sdkserver
``` ```
##### Using xtasks (use this if stupid) ##### Using xtasks (use this if stupid)
@ -43,23 +44,18 @@ page and download the latest release for your platform.
To begin using the server, you need to run both the SDK server and the game server. To begin using the server, you need to run both the SDK server and the game server.
If you installed from source, Rust's installer should have added .cargo/bin to your
path, so simply run the following:
```sh
gameserver
sdkserver
```
If you installed from pre-built binaries, navigate to the directory where you downloaded If you installed from pre-built binaries, navigate to the directory where you downloaded
the binaries and either a) double-click on the following executable names or b) the binaries and either a) double-click on the following executable names or b)
run the following in a terminal: run the following in a terminal:
```sh ```sh
./gameserver ./gameserver
./dispatch
./sdkserver ./sdkserver
``` ```
##### Note: the `assets` folder should be in the same directory with the `gameserver`, otherwise it will panic.
## Connecting ## Connecting
[Get 2.2 beta client](https://bhrpg-prod.oss-accelerate.aliyuncs.com/client/beta/20240322124944_scfGE0xJXlWtoJ1r/StarRail_2.1.51.zip), [Get 2.2 beta client](https://bhrpg-prod.oss-accelerate.aliyuncs.com/client/beta/20240322124944_scfGE0xJXlWtoJ1r/StarRail_2.1.51.zip),

View file

@ -4,6 +4,9 @@ edition = "2021"
version.workspace = true version.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true
ansi_term.workspace = true
env_logger.workspace = true
tracing.workspace = true tracing.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
serde.workspace = true serde.workspace = true

View file

@ -1,2 +1,3 @@
pub mod data; pub mod data;
pub mod logging;
pub mod util; pub mod util;

View file

@ -1,8 +1,9 @@
pub fn load_or_create_config(path: &str, defaults: &str) -> String { pub fn load_or_create_config(path: &str, defaults: &str) -> String {
if let Ok(data) = std::fs::read_to_string(path) { std::fs::read_to_string(path).map_or_else(
data |_| {
} else {
std::fs::write(path, defaults).unwrap(); std::fs::write(path, defaults).unwrap();
defaults.to_string() defaults.to_string()
} },
|data| data,
)
} }

34
dispatch/Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "dispatch"
edition = "2021"
version.workspace = true
[dependencies]
common.workspace = true
anyhow.workspace = true
env_logger.workspace = true
axum.workspace = true
axum-server.workspace = true
lazy_static.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
tracing-futures.workspace = true
tracing-log.workspace = true
tracing-subscriber.workspace = true
tracing-bunyan-formatter.workspace = true
ansi_term.workspace = true
prost.workspace = true
rbase64.workspace = true
proto.workspace = true
tower.workspace = true
tower-http.workspace = true

34
dispatch/dispatch.json Normal file
View file

@ -0,0 +1,34 @@
{
"http_port": 21041,
"game_servers": {
"acheron_sr": {
"name": "AcheronSR",
"title": "AcheronSR",
"dispatch_url": "http://127.0.0.1:21041/query_gateway/acheron_sr",
"env_type": "2",
"gateserver_ip": "127.0.0.1",
"gateserver_port": 23301,
"gateserver_protocol": "Tcp"
}
},
"versions": {
"CNBETAWin2.1.51": {
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_6744505_89b2f5dc973e",
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_6759713_b4e0e740f0da",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_6755976_3c46d7c46e2c",
"lua_version": "6755976"
},
"CNBETAWin2.1.52": {
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_6785106_15237df2ef89",
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_6787319_5f3f1dae4769",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_6785460_26c4b6c61a8b",
"lua_version": "6785460"
},
"CNBETAWin2.1.53": {
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_6828321_72f2df86102b",
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_6834225_44836493b261",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_6828764_f749b48347fd",
"lua_version": "6828764"
}
}
}

51
dispatch/src/config.rs Normal file
View file

@ -0,0 +1,51 @@
use std::collections::HashMap;
use common::util::load_or_create_config;
use lazy_static::lazy_static;
use serde::Deserialize;
use serde_json::from_str;
const DEFAULT_CONFIG: &str = include_str!("../dispatch.json");
pub fn init_config() {
let _configuration = &*CONFIGURATION;
}
#[derive(Deserialize)]
pub struct DispatchServerConfiguration {
pub http_port: u16,
pub game_servers: HashMap<String, GameServerConfig>,
pub versions: HashMap<String, VersionConfig>,
}
#[derive(Deserialize)]
pub struct VersionConfig {
pub asset_bundle_url: String,
pub ex_resource_url: String,
pub lua_url: String,
pub lua_version: String,
}
#[derive(Deserialize)]
pub struct GameServerConfig {
pub name: String,
pub title: String,
pub dispatch_url: String,
pub env_type: String,
pub gateserver_ip: String,
pub gateserver_port: u16,
pub gateserver_protocol: GatewayProtocolType,
}
#[derive(Deserialize, Eq, PartialEq)]
pub enum GatewayProtocolType {
Tcp,
Kcp,
}
lazy_static! {
pub static ref CONFIGURATION: DispatchServerConfiguration = {
let data = load_or_create_config("dispatch.json", DEFAULT_CONFIG);
from_str(&data).unwrap()
};
}

85
dispatch/src/handlers.rs Normal file
View file

@ -0,0 +1,85 @@
use crate::config::*;
use axum::extract::{Path, Query};
use prost::Message;
use proto::{Dispatch, Gateserver, RegionInfo};
use serde::Deserialize;
pub const QUERY_DISPATCH_PATH: &str = "/query_dispatch";
pub const QUERY_GATEWAY_PATH: &str = "/query_gateway/:region_name";
#[tracing::instrument]
pub async fn query_dispatch() -> String {
let rsp = Dispatch {
retcode: 0,
region_list: CONFIGURATION
.game_servers
.iter()
.map(|(_, c)| RegionInfo {
name: c.name.clone(),
title: c.title.clone(),
env_type: c.env_type.clone(),
dispatch_url: c.dispatch_url.clone(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut buff = Vec::new();
rsp.encode(&mut buff).unwrap();
rbase64::encode(&buff)
}
#[derive(Deserialize, Debug)]
pub struct QueryGatewayParameters {
pub version: String,
}
#[tracing::instrument]
pub async fn query_gateway(
Path(region_name): Path<String>,
parameters: Query<QueryGatewayParameters>,
) -> String {
let rsp = if let Some(server_config) = CONFIGURATION.game_servers.get(&region_name) {
if let Some(version_config) = CONFIGURATION.versions.get(&parameters.version) {
Gateserver {
retcode: 0,
ip: server_config.gateserver_ip.clone(),
port: server_config.gateserver_port as u32,
asset_bundle_url: version_config.asset_bundle_url.clone(),
ex_resource_url: version_config.ex_resource_url.clone(),
lua_url: version_config.lua_url.clone(),
lua_version: version_config.lua_version.clone(),
ifix_version: String::from("0"),
jblkncaoiao: true,
hjdjakjkdbi: true,
ldknmcpffim: true,
feehapamfci: true,
eebfeohfpph: true,
dfmjjcfhfea: true,
najikcgjgan: true,
giddjofkndm: true,
use_tcp: server_config.gateserver_protocol == GatewayProtocolType::Tcp,
..Default::default()
}
} else {
Gateserver {
retcode: 9,
msg: format!("forbidden version: {} or invalid bind", parameters.version),
..Default::default()
}
}
} else {
Gateserver {
retcode: 9,
msg: format!("server config for {region_name} not found"),
..Default::default()
}
};
let mut buff = Vec::new();
rsp.encode(&mut buff).unwrap();
rbase64::encode(&buff)
}

35
dispatch/src/main.rs Normal file
View file

@ -0,0 +1,35 @@
use anyhow::Result;
use axum::{extract::Request, routing::get, Router, ServiceExt};
use common::logging::init_tracing;
use tokio::net::TcpListener;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
use tracing::Level;
mod config;
mod handlers;
use config::{init_config, CONFIGURATION};
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
init_config();
let span = tracing::span!(Level::DEBUG, "main");
let _ = span.enter();
let app = Router::new()
.route(handlers::QUERY_DISPATCH_PATH, get(handlers::query_dispatch))
.route(handlers::QUERY_GATEWAY_PATH, get(handlers::query_gateway));
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
let addr = format!("0.0.0.0:{}", CONFIGURATION.http_port);
let server = TcpListener::bind(&addr).await?;
tracing::info!("dispatch is listening at {addr}");
axum::serve(server, ServiceExt::<Request>::into_make_service(app)).await?;
Ok(())
}

View file

@ -1,13 +1,12 @@
use anyhow::Result; use anyhow::Result;
mod game; mod game;
mod logging;
mod net; mod net;
mod util; mod util;
use common::data::init_assets; use common::data::init_assets;
use common::logging::init_tracing;
use game::init_config; use game::init_config;
use logging::init_tracing;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {

View file

@ -1,8 +1,9 @@
use anyhow::Result; use anyhow::Result;
use common::log_error;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::{info_span, Instrument}; use tracing::{info_span, Instrument};
use crate::{log_error, net::PlayerSession}; use crate::net::PlayerSession;
pub async fn listen(host: &str, port: u16) -> Result<()> { pub async fn listen(host: &str, port: u16) -> Result<()> {
let listener = TcpListener::bind(format!("{host}:{port}")).await?; let listener = TcpListener::bind(format!("{host}:{port}")).await?;

View file

@ -11,6 +11,8 @@ env_logger.workspace = true
axum.workspace = true axum.workspace = true
axum-server.workspace = true axum-server.workspace = true
hyper.workspace = true
hyper-util.workspace = true
dirs.workspace = true dirs.workspace = true
dotenv.workspace = true dotenv.workspace = true
@ -30,8 +32,6 @@ tracing-subscriber.workspace = true
tracing-bunyan-formatter.workspace = true tracing-bunyan-formatter.workspace = true
ansi_term.workspace = true ansi_term.workspace = true
prost.workspace = true
rbase64.workspace = true
proto.workspace = true
tower.workspace = true tower.workspace = true
tower-http.workspace = true tower-http.workspace = true

4
sdkserver/sdkserver.json Normal file
View file

@ -0,0 +1,4 @@
{
"http_port": 21000,
"dispatch_endpoint": "http://127.0.0.1:21041"
}

23
sdkserver/src/config.rs Normal file
View file

@ -0,0 +1,23 @@
use common::util::load_or_create_config;
use lazy_static::lazy_static;
use serde::Deserialize;
use serde_json::from_str;
const DEFAULT_CONFIG: &str = include_str!("../sdkserver.json");
pub fn init_config() {
let _configuration = &*CONFIGURATION;
}
#[derive(Deserialize)]
pub struct SDKServerConfiguration {
pub http_port: u16,
pub dispatch_endpoint: String,
}
lazy_static! {
pub static ref CONFIGURATION: SDKServerConfiguration = {
let data = load_or_create_config("sdkserver.json", DEFAULT_CONFIG);
from_str(&data).unwrap()
};
}

View file

@ -1,7 +0,0 @@
mod version_config;
pub use version_config::INSTANCE as versions;
pub fn init_config() {
tracing::info!("loaded {} version configs", versions.len());
}

View file

@ -1,23 +0,0 @@
use std::collections::HashMap;
use common::util::load_or_create_config;
use lazy_static::lazy_static;
use serde::Deserialize;
use serde_json::from_str;
const DEFAULT_VERSIONS: &str = include_str!("../../versions.json");
#[derive(Deserialize)]
pub struct VersionConfig {
pub asset_bundle_url: String,
pub ex_resource_url: String,
pub lua_url: String,
pub lua_version: String,
}
lazy_static! {
pub static ref INSTANCE: HashMap<String, VersionConfig> = {
let data = load_or_create_config("versions.json", DEFAULT_VERSIONS);
from_str(&data).unwrap()
};
}

View file

@ -1,29 +0,0 @@
#[macro_export]
macro_rules! log_error {
($e:expr) => {
if let Err(e) = $e {
tracing::error!(error.message = %format!("{}", &e), "{:?}", e);
}
};
($context:expr, $e:expr $(,)?) => {
if let Err(e) = $e {
let e = format!("{:?}", ::anyhow::anyhow!(e).context($context));
tracing::error!(error.message = %format!("{}", &e), "{:?}", e);
}
};
($ok_context:expr, $err_context:expr, $e:expr $(,)?) => {
if let Err(e) = $e {
let e = format!("{:?}", ::anyhow::anyhow!(e).context($err_context));
tracing::error!(error.message = %format!("{}", &e), "{:?}", e);
} else {
tracing::info!($ok_context);
}
};
}
pub fn init_tracing() {
#[cfg(target_os = "windows")]
ansi_term::enable_ansi_support().unwrap();
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
}

View file

@ -1,21 +1,24 @@
use anyhow::Result; use anyhow::Result;
use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Router, ServiceExt}; use axum::{Router, ServiceExt};
use services::{auth, dispatch, errors}; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
use services::{auth, errors};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::Layer; use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer; use tower_http::normalize_path::NormalizePathLayer;
use tracing::Level; use tracing::Level;
type Client = hyper_util::client::legacy::Client<HttpConnector, Body>;
mod config; mod config;
mod logging;
mod services; mod services;
use config::init_config; use common::logging::init_tracing;
use logging::init_tracing;
const PORT: u16 = 21000; use config::{init_config, CONFIGURATION};
use services::reverse_proxy;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -25,14 +28,16 @@ async fn main() -> Result<()> {
let span = tracing::span!(Level::DEBUG, "main"); let span = tracing::span!(Level::DEBUG, "main");
let _ = span.enter(); let _ = span.enter();
// For dispatch reverse proxy
let client: Client =
hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
.build(HttpConnector::new());
let app = Router::new() let app = Router::new()
.route("/query_dispatch", get(reverse_proxy::forward_to_dispatch))
.route( .route(
dispatch::QUERY_DISPATCH_ENDPOINT, "/query_gateway/:region_name",
get(dispatch::query_dispatch), get(reverse_proxy::forward_to_dispatch),
)
.route(
dispatch::QUERY_GATEWAY_ENDPOINT,
get(dispatch::query_gateway),
) )
.route(auth::RISKY_API_CHECK_ENDPOINT, post(auth::risky_api_check)) .route(auth::RISKY_API_CHECK_ENDPOINT, post(auth::risky_api_check))
.route( .route(
@ -47,11 +52,12 @@ async fn main() -> Result<()> {
auth::GRANTER_LOGIN_VERIFICATION_ENDPOINT, auth::GRANTER_LOGIN_VERIFICATION_ENDPOINT,
post(auth::granter_login_verification), post(auth::granter_login_verification),
) )
.fallback(errors::not_found); .fallback(errors::not_found)
.with_state(client);
let app = NormalizePathLayer::trim_trailing_slash().layer(app); let app = NormalizePathLayer::trim_trailing_slash().layer(app);
let addr = format!("0.0.0.0:{PORT}"); let addr = format!("0.0.0.0:{}", CONFIGURATION.http_port);
let server = TcpListener::bind(&addr).await?; let server = TcpListener::bind(&addr).await?;
tracing::info!("sdkserver is listening at {addr}"); tracing::info!("sdkserver is listening at {addr}");

View file

@ -1,75 +0,0 @@
use crate::config::versions;
use axum::extract::Query;
use prost::Message;
use proto::{Dispatch, Gateserver, RegionInfo};
use serde::Deserialize;
pub const QUERY_DISPATCH_ENDPOINT: &str = "/query_dispatch";
pub const QUERY_GATEWAY_ENDPOINT: &str = "/query_gateway";
#[tracing::instrument]
pub async fn query_dispatch() -> String {
let rsp = Dispatch {
retcode: 0,
region_list: vec![RegionInfo {
name: String::from("AcheronSR"),
title: String::from("AcheronSR"),
env_type: String::from("2"),
dispatch_url: String::from("http://127.0.0.1:21000/query_gateway"),
..Default::default()
}],
..Default::default()
};
let mut buff = Vec::new();
rsp.encode(&mut buff).unwrap();
rbase64::encode(&buff)
}
#[derive(Deserialize, Debug)]
pub struct QueryGatewayParameters {
pub version: String,
}
#[tracing::instrument]
pub async fn query_gateway(parameters: Query<QueryGatewayParameters>) -> String {
let rsp = if let Some(config) = versions.get(&parameters.version) {
Gateserver {
retcode: 0,
ip: String::from("127.0.0.1"),
port: 23301,
asset_bundle_url: config.asset_bundle_url.clone(),
ex_resource_url: config.ex_resource_url.clone(),
lua_url: config.lua_url.clone(),
lua_version: config.lua_version.clone(),
ifix_version: String::from("0"),
jblkncaoiao: true,
hjdjakjkdbi: true,
ldknmcpffim: true,
feehapamfci: true,
eebfeohfpph: true,
dfmjjcfhfea: true,
najikcgjgan: true,
giddjofkndm: true,
fbnbbembcgn: false,
dedgfjhbnok: false,
use_tcp: true,
linlaijbboh: false,
ahmbfbkhmgh: false,
nmdccehcdcc: false,
..Default::default()
}
} else {
Gateserver {
retcode: 9,
msg: format!("forbidden version: {} or invalid bind", parameters.version),
..Default::default()
}
};
let mut buff = Vec::new();
rsp.encode(&mut buff).unwrap();
rbase64::encode(&buff)
}

View file

@ -1,3 +1,3 @@
pub mod auth; pub mod auth;
pub mod dispatch;
pub mod errors; pub mod errors;
pub mod reverse_proxy;

View file

@ -0,0 +1,33 @@
use axum::{
body::Body,
extract::{Request, State},
http::uri::{PathAndQuery, Uri},
response::{IntoResponse, Response},
};
use hyper::StatusCode;
use hyper_util::client::legacy::connect::HttpConnector;
use crate::config::CONFIGURATION;
type Client = hyper_util::client::legacy::Client<HttpConnector, Body>;
pub async fn forward_to_dispatch(
State(client): State<Client>,
mut req: Request,
) -> Result<Response, StatusCode> {
let path = req.uri().path();
let path_query = req
.uri()
.path_and_query()
.map_or(path, PathAndQuery::as_str);
let uri = format!("{}{}", CONFIGURATION.dispatch_endpoint, path_query);
*req.uri_mut() = Uri::try_from(uri).unwrap();
Ok(client
.request(req)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
.into_response())
}

View file

@ -1,20 +0,0 @@
{
"CNBETAWin2.1.51": {
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_6744505_89b2f5dc973e",
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_6759713_b4e0e740f0da",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_6755976_3c46d7c46e2c",
"lua_version": "6755976"
},
"CNBETAWin2.1.52": {
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_6785106_15237df2ef89",
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_6787319_5f3f1dae4769",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_6785460_26c4b6c61a8b",
"lua_version": "6785460"
},
"CNBETAWin2.1.53": {
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_6828321_72f2df86102b",
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_6834225_44836493b261",
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_6828764_f749b48347fd",
"lua_version": "6828764"
}
}

View file

@ -36,6 +36,7 @@ fn spawn_servers(release: bool) -> Result<(), Box<dyn std::error::Error>> {
Ok::<(), io::Error>(()) Ok::<(), io::Error>(())
}); });
let tx2 = tx.clone();
let handle2 = thread::spawn(move || { let handle2 = thread::spawn(move || {
let mut sdkserver = Command::new("cargo") let mut sdkserver = Command::new("cargo")
.arg("run") .arg("run")
@ -46,6 +47,21 @@ fn spawn_servers(release: bool) -> Result<(), Box<dyn std::error::Error>> {
.expect("failed to start sdkserver"); .expect("failed to start sdkserver");
let _ = sdkserver.wait()?; let _ = sdkserver.wait()?;
tx2.send(()).expect("failed to send completion signal");
Ok::<(), io::Error>(())
});
let handle3 = thread::spawn(move || {
let mut dispatch = Command::new("cargo")
.arg("run")
.arg("--bin")
.arg("dispatch")
.args(if release { vec!["--release"] } else { vec![] })
.spawn()
.expect("failed to start dispatch");
let _ = dispatch.wait()?;
tx.send(()).expect("failed to send completion signal"); tx.send(()).expect("failed to send completion signal");
Ok::<(), io::Error>(()) Ok::<(), io::Error>(())
@ -55,6 +71,7 @@ fn spawn_servers(release: bool) -> Result<(), Box<dyn std::error::Error>> {
handle1.join().expect("failed to join gameserver thread")?; handle1.join().expect("failed to join gameserver thread")?;
handle2.join().expect("failed to join sdkserver thread")?; handle2.join().expect("failed to join sdkserver thread")?;
handle3.join().expect("failed to join dispatch thread")?;
Ok(()) Ok(())
} }