diff --git a/Cargo.lock b/Cargo.lock index a408fb3..adabf93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ version = "0.26.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962e462a6778bf5c3d4f169f7b76032de7d443999d9dd02ee9ec68063259b2a9" dependencies = [ - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -688,6 +688,26 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.114", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -957,6 +977,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-expr" version = "0.20.5" @@ -993,6 +1022,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.54" @@ -4732,6 +4772,15 @@ dependencies = [ "arrays", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -5442,7 +5491,7 @@ dependencies = [ "futures", "github-app-auth", "if_chain", - "itertools", + "itertools 0.14.0", "log-lock", "multiworld-derive", "nonempty-collections", @@ -5479,7 +5528,7 @@ dependencies = [ "clap", "crossterm", "futures", - "itertools", + "itertools 0.14.0", "multiworld", "ootr-utils", "serde_json_path_to_error", @@ -5510,7 +5559,7 @@ dependencies = [ "async-proto", "chrono", "directories", - "itertools", + "itertools 0.14.0", "libc", "multiworld", "multiworld-derive", @@ -5529,7 +5578,7 @@ dependencies = [ name = "multiworld-derive" version = "17.1.5" dependencies = [ - "itertools", + "itertools 0.14.0", "lazy-regex", "proc-macro2", "quote", @@ -5551,9 +5600,10 @@ dependencies = [ "iced", "if_chain", "image", - "itertools", + "itertools 0.14.0", "log-lock", "multiworld", + "n64flashcart", "num-traits", "oauth2", "once_cell", @@ -5598,7 +5648,7 @@ dependencies = [ "if_chain", "image", "is_elevated", - "itertools", + "itertools 0.14.0", "kuchiki", "lazy-regex", "mslnk", @@ -5631,7 +5681,7 @@ version = "17.1.5" dependencies = [ "clap", "futures", - "itertools", + "itertools 0.14.0", "lazy-regex", "semver", "sha2", @@ -5655,7 +5705,7 @@ dependencies = [ "github-app-auth", "graphql_client", "gres", - "itertools", + "itertools 0.14.0", "lazy-regex", "log-lock", "multiworld", @@ -5704,7 +5754,7 @@ dependencies = [ "futures", "iced", "image", - "itertools", + "itertools 0.14.0", "multiworld", "open", "reqwest 0.13.1", @@ -5755,6 +5805,14 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "n64flashcart" +version = "0.1.0" +source = "git+https://github.com/mracsys/n64usb#21b9e3d25ff775e71c9dbf0b34de55f93b97520c" +dependencies = [ + "bindgen", +] + [[package]] name = "naga" version = "27.0.3" @@ -6440,7 +6498,7 @@ dependencies = [ "directories", "enum-iterator", "gix", - "itertools", + "itertools 0.14.0", "lazy-regex", "semver", "serde", @@ -6505,7 +6563,7 @@ dependencies = [ "enum-iterator", "futures", "image", - "itertools", + "itertools 0.14.0", "ootr", "oottracker-derive", "reqwest 0.12.28", @@ -6526,7 +6584,7 @@ version = "0.7.4" source = "git+https://github.com/fenhl/oottracker?branch=mw#ee24381752d3ec42445f2b5e521789eeaa21108e" dependencies = [ "convert_case 0.8.0", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -6989,6 +7047,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -7082,7 +7150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -7368,7 +7436,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -10690,7 +10758,7 @@ dependencies = [ "futures", "gio", "iced", - "itertools", + "itertools 0.14.0", "reqwest 0.13.1", "rocket", "rocket-util", @@ -10710,7 +10778,7 @@ name = "wheel-derive" version = "0.15.3" source = "git+https://github.com/fenhl/wheel#d1460622d5ba4b8529e8c2d97f8db664b2738624" dependencies = [ - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", diff --git a/crate/multiworld-gui/Cargo.toml b/crate/multiworld-gui/Cargo.toml index f531840..c9258be 100644 --- a/crate/multiworld-gui/Cargo.toml +++ b/crate/multiworld-gui/Cargo.toml @@ -33,6 +33,7 @@ image = { version = "0.25", default-features = false, features = ["ico"] } itertools = "0.14" log-lock = { git = "https://github.com/fenhl/log-lock" } multiworld = { path = "../multiworld" } +n64flashcart = { git = "https://github.com/mracsys/n64usb" } num-traits = { version = "0.2.19", default-features = false } oauth2 = "4" once_cell = "1" diff --git a/crate/multiworld-gui/src/flashcart.rs b/crate/multiworld-gui/src/flashcart.rs new file mode 100644 index 0000000..01d5b8e --- /dev/null +++ b/crate/multiworld-gui/src/flashcart.rs @@ -0,0 +1,465 @@ +use { + crate::{FrontendWriter, Message}, arrayref::{array_mut_ref, array_ref}, enum_iterator::all, futures::{TryStreamExt as _, stream::{ + self, + Stream, + StreamExt as _, + }}, iced::advanced::subscription::{ + EventStream, + Recipe, + }, multiworld::{Filename, frontend::{ClientMessage, ServerMessage}}, n64flashcart, ootr_utils::spoiler::HashIcon, std::{ + any::TypeId, collections::{HashMap, VecDeque}, hash::Hash as _, num::NonZeroU8, pin::Pin, sync::Arc, time::Duration + }, tokio::{select, sync::{Mutex, mpsc}, time::{sleep, timeout}} +}; + +const DEBUG_LOGGING: bool = true; + +macro_rules! dbg_println { + ($($arg:tt)*) => { + if DEBUG_LOGGING { + println!($($arg)*); + } + }; +} + +const PROTOCOL_VERSION: u8 = 1; +const MW_SEND_OWN_ITEMS: u8 = 1; +const MW_PROGRESSIVE_ITEMS_ENABLE: u8 = 1; + +const CMD_PLAYER_DATA: u8 = 1; +const CMD_GET_ITEM: u8 = 2; +const N64_ITEM_SEND: u8 = 3; +const N64_ITEM_RECEIVED: u8 = 4; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + #[error("Flashcart disconnected")] + #[expect(dead_code)] + Disconnected, + #[error("received item for world 0")] + PlayerId, +} + +pub(crate) struct Subscription { +// pub(crate) log: bool, +} + +#[derive(Debug, Clone)] +pub(crate) enum InGameState { + Unknown, + FileSelect, + InGame, + Receiving +} + +#[derive(Debug)] +pub(crate) struct InGameStruct { + rx: mpsc::Receiver, + item_queue: VecDeque, + ingame_state: InGameState, + filename: Option, + player_data: HashMap, +} + +#[derive(Debug, Clone)] +pub(crate) enum CommState { + SendHandshake, + WaitForGame, + Handshake, + Ready(Arc>) +} + +#[derive(Debug, Clone)] +pub enum FlashcartState { + DISCONNECTED, + SEARCHING, + OPENING(String), + CONNECTED(String, CommState) +} + +const FIVE_SECONDS : Duration = Duration::new(5, 0); + +fn send_handshake() { + let msg = "cmdt".as_bytes().to_vec(); + let header = n64flashcart::Header { datatype: n64flashcart::USBDataType::TEXT, length: msg.len() }; + + let _ = n64flashcart::write(header, msg); +} + +fn send_handshake_response() -> Result<(), n64flashcart::DeviceError> { + let mut msg = "MW".as_bytes().to_vec(); + msg.push(PROTOCOL_VERSION); + msg.push(MW_SEND_OWN_ITEMS); // MW_SEND_OWN_ITEMS + msg.push(MW_PROGRESSIVE_ITEMS_ENABLE); // MW_PROGRESSIVE_ITEMS_ENABLE + let header = n64flashcart::Header { datatype: n64flashcart::USBDataType::RAWBINARY, length: msg.len() }; + let status = n64flashcart::write(header, msg); + + match status { + n64flashcart::DeviceError::OK => Ok(()), + _ => Err(status) + } +} + +async fn n64_recv() -> Result<(n64flashcart::Header, Vec), n64flashcart::DeviceError> { + loop { + match n64flashcart::read() { + Ok((header, data)) => { + if header.datatype != n64flashcart::USBDataType::EMPTY { + return Ok((header, data)); + } + } + Err(e) => return Err(e), + }; + } +} + +fn n64_send(datatype: n64flashcart::USBDataType, msg: Vec) -> Result<(), n64flashcart::DeviceError> { + let header = n64flashcart::Header { datatype: datatype, length: msg.len() }; + let status = n64flashcart::write(header, msg); + match status { + n64flashcart::DeviceError::OK => Ok(()), + _ => Err(status) + } +} + +fn send_player_data(world: NonZeroU8, name: Filename, progressive_items: u32) -> Result<(), n64flashcart::DeviceError> { + let mut buf = [0; 16]; + + buf[0] = CMD_PLAYER_DATA; + buf[1] = world.get(); + *array_mut_ref![buf, 2, 8] = name.0; + *array_mut_ref![buf, 10, 4] = progressive_items.to_be_bytes(); + + n64_send(n64flashcart::USBDataType::RAWBINARY, Vec::from(buf)) +} + +fn send_item(item: u16) -> Result<(), n64flashcart::DeviceError> { + let mut msg: Vec = Vec::new(); + + msg.push(CMD_GET_ITEM); + + let [b1, b2] = item.to_be_bytes(); + msg.push(b1); + msg.push(b2); + + n64_send(n64flashcart::USBDataType::RAWBINARY, msg) +} + +fn process_n64_packet(header: n64flashcart::Header, data: Vec, struc: &mut InGameStruct) -> (Option, Option>) +{ + match header.datatype { + n64flashcart::USBDataType::INGAME_STATE => dbg_println!("Datatype: {:?}, Length: {}", header.datatype, header.length), + _ => dbg_println!("Datatype: {:?}, Length: {}, data: {:?}", header.datatype, header.length, data) + }; + + match header.datatype { + n64flashcart::USBDataType::SAVE_FILENAME => { + if data.len() >= 9 { + let data_slice = data.into_boxed_slice(); + let fname = *array_ref![data_slice, 1, 8]; + + match fname { + [0, 0, 0, 0, 0, 0, 0, 0] => { + (Some(InGameState::FileSelect), None) + }, + _ => { + let filename = Filename(fname); + if let None = struc.filename { + struc.filename = Some(filename); + } + + (Some(InGameState::InGame), Some(vec![Message::Plugin(Box::new(ClientMessage::PlayerName(filename)))])) + } + + } + } + else { + (None, None) + } + }, + + n64flashcart::USBDataType::INGAME_STATE => { + if let Ok(savedata) = TryInto::<[u8 ; 5200]>::try_into(data) { + let mut messages = Vec::new(); + + if let None = struc.filename { + let filename = Filename(*array_ref![savedata, 0x024, 8]); + struc.filename = Some(filename); + messages.push(Message::Plugin(Box::new(ClientMessage::PlayerName(filename)))); + } + + messages.push(Message::Plugin(Box::new(ClientMessage::SaveData(savedata)))); + + (Some(InGameState::InGame), Some(messages)) + } else { + (None, None) + } + }, + + n64flashcart::USBDataType::RAWBINARY => { + match data[0] { + N64_ITEM_SEND => { // Item sent + let data_slice = data.into_boxed_slice(); + + let kind = u16::from_be_bytes(*array_ref![data_slice, 9, 2]); + let target_world = NonZeroU8::new(data_slice[11]).ok_or(Error::PlayerId).unwrap(); + + dbg_println!("Got item {} for world {}", kind, target_world); + + let message = Message::Plugin(Box::new(ClientMessage::SendItem { + key: u64::from_be_bytes(*array_ref![data_slice, 1, 8]), + kind: kind, + target_world: target_world, + })); + + (None, Some(vec![message])) + } + N64_ITEM_RECEIVED => (Some(InGameState::InGame), None), // Item receive confirmation + _ => (None, None) + } + }, + + _ => (None, None) + } +} + +fn process_message(comm_state: &CommState, header: n64flashcart::Header, data: Vec) -> (Option, Vec) { + let mut messages = Vec::new(); + let next_comm_state = match comm_state { + CommState::SendHandshake => { + send_handshake(); + Some(CommState::Handshake) + } + CommState::WaitForGame => { + if header.datatype == n64flashcart::USBDataType::HANDSHAKE { + Some(CommState::SendHandshake) + } else { + None + } + } + CommState::Handshake => { + match TryInto::<[u8 ; 16]>::try_into(data) { + Ok(value) => { + match value { + [b'O', b'o', b'T', b'R', PROTOCOL_VERSION, major, minor, patch, branch, supplementary, player_id, hash1, hash2, hash3, hash4, hash5] => { + dbg_println!("Handshake reply received. Repeating protocol version to finalize handshake"); + let res = send_handshake_response(); + match res { + Ok(_) => { + dbg_println!("Protocol version sent"); + + let _version = ootr_utils::Version::from_bytes([major, minor, patch, branch, supplementary]).unwrap(); + let player_id = NonZeroU8::new(player_id).unwrap(); + let file_hash: [HashIcon; 5] = [ + all().nth(hash1.into()).unwrap(), + all().nth(hash2.into()).unwrap(), + all().nth(hash3.into()).unwrap(), + all().nth(hash4.into()).unwrap(), + all().nth(hash5.into()).unwrap(), + ]; + + let (tx, rx) = mpsc::channel(1_024); + + messages.push(Message::FrontendConnected(FrontendWriter::Mpsc(tx))); + messages.push(Message::Plugin(Box::new(ClientMessage::PlayerId(player_id)))); + messages.push(Message::Plugin(Box::new(ClientMessage::FileHash(Some(file_hash))))); + + let struc = InGameStruct { + rx: rx, + item_queue: VecDeque::new(), + ingame_state: InGameState::Unknown, + filename: None, + player_data: HashMap::default() + }; + + Some(CommState::Ready(Arc::new(Mutex::new(struc)))) + }, + Err(_) => { + dbg_println!("Failed to send protocol version, restarting handshake"); + Some(CommState::WaitForGame) + } + } + }, + _ => { + dbg_println!("Invalid handshake reply, restarting handshake"); + Some(CommState::WaitForGame) + } + } + }, + Err(_) => { + dbg_println!("Invalid handshake reply, restarting handshake"); + Some(CommState::WaitForGame) + } + } + }, + CommState::Ready(_) => None, + }; + + (next_comm_state, messages) +} + +async fn read(name: &String, comm_state: &CommState) -> (Option, Vec) { + let next_state = match comm_state { + CommState::SendHandshake => { + send_handshake(); + Some(FlashcartState::CONNECTED(name.to_owned(), CommState::Handshake)) + }, + CommState::Ready(_struc) => { + let mut struc = _struc.lock().await; + let mut messages = Vec::new(); + + if !struc.item_queue.is_empty() { + if let InGameState::InGame = struc.ingame_state { + let item = struc.item_queue.pop_front().unwrap(); + let _ = send_item(item); + + struc.ingame_state = InGameState::Receiving; + } + } + + select! { + n64_or_timeout = timeout(Duration::from_secs(5), n64_recv()) => { + match n64_or_timeout { + Ok(n64_result) => { + match n64_result { + Ok((header, data)) => { + let (state_, messages_) = process_n64_packet(header, data, &mut struc); + if let Some(value) = state_ { + struc.ingame_state = value; + } + if let Some(msg) = messages_ { + messages.extend(msg); + } + }, + Err(e) => { + dbg_println!("Error receiving from n64, {:?}", e); + return (Some(FlashcartState::DISCONNECTED), vec![]); + } + } + }, + Err(_) => { + dbg_println!("No message from N64 in 5 seconds"); + return (Some(FlashcartState::DISCONNECTED), vec![]); + } + }; + }, + Some(msg) = struc.rx.recv() => { + dbg_println!("Received message from MH, {:?}", msg); + match msg { + ServerMessage::ItemQueue(items) => { + struc.item_queue.extend(items); + }, + ServerMessage::GetItem(item) => { + struc.item_queue.push_back(item); + }, + ServerMessage::PlayerName(world, new_name) => { + let (name, progressive_items) = struc.player_data.entry(world).or_default(); + *name = new_name; + let _ = send_player_data(world, *name, *progressive_items); + }, + ServerMessage::ProgressiveItems(world, new_progressive_items) => { + let (name, progressive_items) = struc.player_data.entry(world).or_default(); + *progressive_items = new_progressive_items; + let _ = send_player_data(world, *name, *progressive_items); + }, + } + + } + }; + + dbg_println!("InGameState: {:?}", struc.ingame_state); + return (None, messages); + }, + _ => { + match n64flashcart::read() { + Ok((header, data)) => { + if header.datatype == n64flashcart::USBDataType::EMPTY { + // Do nothing, no data + None + } + else { + let (new_comm_state, messages) = process_message(comm_state, header, data); + let next_state = new_comm_state.map(|state| FlashcartState::CONNECTED(name.to_owned(), state)); + return (next_state, messages); + } + } + Err(e) => { + dbg_println!("Read error while waiting for handshake, {}", e.value()); + //sleep(FIVE_SECONDS).await; + Some(FlashcartState::DISCONNECTED) + } + } + } + }; + + (next_state, vec![]) +} + +impl Recipe for Subscription { + type Output = Message; + + fn hash(&self, state: &mut iced::advanced::subscription::Hasher) { + TypeId::of::().hash(state); + } + + fn stream(self: Box, _: EventStream) -> Pin + Send>> { + stream::try_unfold(FlashcartState::SEARCHING, |state| async move { + let _ = sleep(Duration::from_millis(100)).await; + let mut messages: Vec = Vec::new(); + + let new_state = match &state { + FlashcartState::DISCONNECTED => { + let _ = sleep(FIVE_SECONDS).await; + Some(FlashcartState::SEARCHING) + }, + FlashcartState::SEARCHING => { + let status = n64flashcart::find(); + if status == n64flashcart::DeviceError::CARTFINDFAIL { + n64flashcart::initialize(); + Some(FlashcartState::DISCONNECTED) + } else if status != n64flashcart::DeviceError::OK { + Some(FlashcartState::DISCONNECTED) + } else { + let cart_name = n64flashcart::cart_type_to_str(n64flashcart::get_cart()); + Some(FlashcartState::OPENING(cart_name.to_string())) + } + }, + FlashcartState::OPENING(name) => { + let status = n64flashcart::open(); + if status != n64flashcart::DeviceError::OK { + dbg_println!("Failed to open USB connection to flashcart, retrying, error code {}", status.value()); + Some(FlashcartState::DISCONNECTED) + } else { + dbg_println!("Flashcart USB connection opened"); + Some(FlashcartState::CONNECTED(name.to_owned(), CommState::SendHandshake)) + } + }, + FlashcartState::CONNECTED(name, comm_state) => { + let (next_state, m) = read(name, comm_state).await; + messages.extend(m); + next_state + } + }; + + if let Some(value) = &new_state { + messages.push(Message::FlashcartStateChanged(value.clone())); + } + + Ok::<_, Error>(Some((stream::iter(messages).map(Ok::<_, Error>), new_state.unwrap_or(state)))) + }).try_flatten().map(|res| { + let mut print_debug = true; + + if let Ok(message) = &res { + if let Message::Plugin(plugin) = message { + if let ClientMessage::SaveData(_) = plugin.as_ref() { + print_debug = false; + } + } + } + + if print_debug { + dbg_println!("{:?}", res); + } + res.unwrap_or_else(|e| Message::FrontendSubscriptionError(Arc::new(e.into()))) + }).boxed() + } +} diff --git a/crate/multiworld-gui/src/main.rs b/crate/multiworld-gui/src/main.rs index 3a019e4..beb51ce 100644 --- a/crate/multiworld-gui/src/main.rs +++ b/crate/multiworld-gui/src/main.rs @@ -1,106 +1,30 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use { - std::{ - borrow::Cow, - cell::RefCell, - collections::{ - BTreeMap, - HashMap, - HashSet, - }, - env, - fmt, - future::Future, - io::prelude::*, - mem, - num::NonZeroU8, - path::{ - Path, - PathBuf, - }, - process, - sync::Arc, - time::Duration, - }, - async_proto::Protocol, - chrono::{ + crate::{ + flashcart::FlashcartState, persistent_state::PersistentState, subscriptions::{ + LoggingSubscription, + WsSink, + } + }, async_proto::Protocol, chrono::{ TimeDelta, prelude::*, - }, - enum_iterator::all, - futures::{ + }, enum_iterator::all, futures::{ future::{ self, FutureExt as _, }, sink::SinkExt as _, stream::Stream, - }, - iced::{ - Element, - Length, - Task, - Size, - Subscription, - advanced::subscription, - clipboard, - widget::*, - window::{ + }, iced::{ + Element, Length, Size, Subscription, Task, advanced::subscription, clipboard, widget::*, window::{ self, icon, - }, - }, - if_chain::if_chain, - ::image::ImageFormat, - itertools::Itertools as _, - log_lock::{ + } + }, if_chain::if_chain, ::image::ImageFormat, itertools::Itertools as _, log_lock::{ Mutex, lock, - }, - oauth2::{ - RefreshToken, - TokenResponse as _, - reqwest::async_http_client, - }, - once_cell::sync::Lazy, - ootr::model::{ - DungeonReward, - Medallion, - Stone, - }, - ootr_utils::spoiler::HashIcon, - open::that as open, - rand::{ - prelude::*, - rng, - }, - rfd::AsyncFileDialog, - semver::Version, - serenity::utils::MessageBuilder, - sysinfo::Pid, - tokio::{ - io::{ - self, - AsyncWriteExt as _, - }, - net::tcp::{ - OwnedReadHalf, - OwnedWriteHalf, - }, - sync::mpsc, - time::{ - Instant, - sleep_until, - }, - }, - tokio_tungstenite::tungstenite, - url::Url, - wheel::{ - fs, - traits::IsNetworkError, - }, - multiworld::{ + }, multiworld::{ DurationFormatter, Filename, HintArea, @@ -123,20 +47,63 @@ use { ServerMessage, }, }, - }, - crate::{ - persistent_state::PersistentState, - subscriptions::{ - LoggingSubscription, - WsSink, + }, oauth2::{ + RefreshToken, + TokenResponse as _, + reqwest::async_http_client, + }, once_cell::sync::Lazy, ootr::model::{ + DungeonReward, + Medallion, + Stone, + }, ootr_utils::spoiler::HashIcon, open::that as open, rand::{ + prelude::*, + rng, + }, rfd::AsyncFileDialog, semver::Version, serenity::utils::MessageBuilder, std::{ + borrow::Cow, + cell::RefCell, + collections::{ + BTreeMap, + HashMap, + HashSet, }, - }, + env, + fmt, + future::Future, + io::prelude::*, + mem, + num::NonZeroU8, + path::{ + Path, + PathBuf, + }, + process, + sync::Arc, + time::Duration, + }, sysinfo::Pid, tokio::{ + io::{ + self, + AsyncWriteExt as _, + }, + net::tcp::{ + OwnedReadHalf, + OwnedWriteHalf, + }, + sync::mpsc, + time::{ + Instant, + sleep_until, + }, + }, tokio_tungstenite::tungstenite, url::Url, wheel::{ + fs, + traits::IsNetworkError, + } }; #[cfg(unix)] use xdg::BaseDirectories; #[cfg(windows)] use directories::ProjectDirs; #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt as _; mod everdrive; +mod flashcart; mod login; mod persistent_state; mod subscriptions; @@ -282,6 +249,7 @@ enum Error { #[error(transparent)] Config(#[from] multiworld::config::Error), #[error(transparent)] Elapsed(#[from] tokio::time::error::Elapsed), #[error(transparent)] EverDrive(#[from] everdrive::Error), + #[error(transparent)] Flashcart(#[from] flashcart::Error), #[error(transparent)] Http(#[from] tungstenite::http::Error), #[error(transparent)] InvalidUri(#[from] tungstenite::http::uri::InvalidUri), #[error(transparent)] Io(#[from] io::Error), @@ -313,7 +281,7 @@ impl IsNetworkError for Error { fn is_network_error(&self) -> bool { match self { Self::Elapsed(_) => true, - Self::Config(_) | Self::EverDrive(_) | Self::Http(_) | Self::InvalidUri(_) | Self::Json(_) | Self::MpscFrontendSend(_) | Self::PersistentState(_) | Self::Semver(_) | Self::Url(_) | Self::InvalidPj64ScriptPath | Self::VersionMismatch { .. } => false, + Self::Config(_) | Self::EverDrive(_) | Self::Flashcart(_) | Self::Http(_) | Self::InvalidUri(_) | Self::Json(_) | Self::MpscFrontendSend(_) | Self::PersistentState(_) | Self::Semver(_) | Self::Url(_) | Self::InvalidPj64ScriptPath | Self::VersionMismatch { .. } => false, Self::Client(e) => e.is_network_error(), Self::Io(e) | Self::Pj64LaunchFailed(e) => e.is_network_error(), Self::Read(e) => e.is_network_error(), @@ -341,6 +309,7 @@ enum Message { DismissWrongPassword, EverDriveScanFailed(Arc>), EverDriveTimeout, + FlashcartStateChanged(FlashcartState), Exit, FrontendConnected(FrontendWriter), FrontendSubscriptionError(Arc), @@ -599,12 +568,18 @@ enum EverDriveState { Timeout, } +#[derive(Debug, Clone)] +struct FrontendFlashcartState { + state: FlashcartState +} + #[derive(Debug, Clone)] struct FrontendState { kind: Frontend, #[cfg(any(target_os = "linux", target_os = "windows"))] bizhawk: Option, everdrive: EverDriveState, + flashcart: FrontendFlashcartState, } impl FrontendState { @@ -612,6 +587,7 @@ impl FrontendState { match self.kind { Frontend::Dummy => "(no frontend)".into(), Frontend::EverDrive => "EverDrive".into(), + Frontend::Flashcart => "Flashcart".into(), #[cfg(any(target_os = "linux", target_os = "windows"))] Frontend::BizHawk => if let Some(BizHawkState { ref version, .. }) = self.bizhawk { format!("BizHawk {version}").into() } else { @@ -626,6 +602,7 @@ impl FrontendState { fn is_locked(&self) -> bool { match self.kind { Frontend::Dummy | Frontend::EverDrive | Frontend::Pj64V3 => false, + Frontend::Flashcart => false, Frontend::Pj64V4 => false, //TODO pass port from PJ64, consider locked if present #[cfg(any(target_os = "linux", target_os = "windows"))] Frontend::BizHawk => self.bizhawk.is_some(), #[cfg(not(any(target_os = "linux", target_os = "windows")))] Frontend::BizHawk => unreachable!("no BizHawk support on this platform"), @@ -662,6 +639,9 @@ impl State { None }, everdrive: EverDriveState::default(), + flashcart: FrontendFlashcartState { + state: FlashcartState::DISCONNECTED + } }; Self { debug_info_copied: HashSet::default(), @@ -753,7 +733,8 @@ impl State { cmd.arg("everdrive"); cmd.arg(env::current_exe()?); cmd.arg(process::id().to_string()); - } + }, + Frontend::Flashcart => return Ok(Message::UpToDate), Frontend::BizHawk => if let Some(BizHawkState { path, pid, version, port: _ }) = frontend.bizhawk { cmd.arg("bizhawk"); cmd.arg(process::id().to_string()); @@ -855,6 +836,15 @@ impl State { if let Frontend::EverDrive = self.frontend.kind { self.frontend_writer = None; } + }, + Message::FlashcartStateChanged(state) => { + self.frontend.flashcart.state = state; + + if let Frontend::Flashcart = self.frontend.kind { + if let FlashcartState::DISCONNECTED = self.frontend.flashcart.state { + self.frontend_writer = None; + } + } } Message::Exit => return iced::exit(), Message::FrontendConnected(inner) => { @@ -1564,6 +1554,22 @@ impl State { .push("Connection to EverDrive lost") .push("Retrying in 5 seconds…"), }, + Frontend::Flashcart => { + match &self.frontend.flashcart.state { + FlashcartState::DISCONNECTED => col = col.push("Disconnected from flashcart, waiting 5 seconds..."), + FlashcartState::SEARCHING => col = col.push("Looking for supported flashcarts"), + FlashcartState::OPENING(name) => col = col.push(Text::new(format!("Opening flashcart {}", name))), + FlashcartState::CONNECTED(name, comm_state) => { + col = col.push(Text::new(format!("Connected to flashcart {}", name))); + col = col.push(match comm_state { + flashcart::CommState::SendHandshake => "Sending handshare", + flashcart::CommState::WaitForGame => "Waiting for game...", + flashcart::CommState::Handshake => "Waiting for handshake response", + flashcart::CommState::Ready(_) => "Ready", + }) + } + } + }, #[cfg(any(target_os = "linux", target_os = "windows"))] Frontend::BizHawk => if self.frontend.bizhawk.is_some() { col = col .push("Waiting for BizHawk…") @@ -1590,7 +1596,7 @@ impl State { } Frontend::Pj64V4 => { col = col - .push("Waiting for Project64…") + .push("Waiting forFrontend::Dummy => {} Project64…") .push("This should take less than 5 seconds."); } } @@ -2045,10 +2051,11 @@ impl State { if !matches!(self.update_state, UpdateState::Pending) { match self.frontend.kind { Frontend::Dummy => {} - Frontend::EverDrive => subscriptions.push(subscription::from_recipe(LoggingSubscription { log: self.log, context: "from EverDrive", inner: everdrive::Subscription { log: self.log } })), + Frontend::EverDrive => subscriptions.push(subscription::from_recipe(LoggingSubscription { log: true, context: "from EverDrive", inner: everdrive::Subscription { log: self.log } })), #[cfg(any(target_os = "linux", target_os = "windows"))] Frontend::BizHawk => if let Some(BizHawkState { port, .. }) = self.frontend.bizhawk { subscriptions.push(subscription::from_recipe(LoggingSubscription { log: self.log, context: "from BizHawk", inner: subscriptions::Connection { port, frontend: self.frontend.kind, log: self.log, connection_id: self.frontend_connection_id } })); }, + Frontend::Flashcart => subscriptions.push(subscription::from_recipe(LoggingSubscription { log: self.log, context: "from Flashcart", inner: flashcart::Subscription { /*log: self.log */ } })), #[cfg(not(any(target_os = "linux", target_os = "windows")))] Frontend::BizHawk => unreachable!("no BizHawk support on this platform"), Frontend::Pj64V3 => subscriptions.push(subscription::from_recipe(LoggingSubscription { log: self.log, context: "from Project64", inner: subscriptions::Listener { frontend: self.frontend.kind, log: self.log, connection_id: self.frontend_connection_id } })), Frontend::Pj64V4 => subscriptions.push(subscription::from_recipe(LoggingSubscription { log: self.log, context: "from Project64", inner: subscriptions::Connection { port: frontend::PORT, frontend: self.frontend.kind, log: self.log, connection_id: self.frontend_connection_id } })), //TODO allow Project64 to specify port via command-line arg diff --git a/crate/multiworld/src/frontend.rs b/crate/multiworld/src/frontend.rs index dad011d..a1c4d9f 100644 --- a/crate/multiworld/src/frontend.rs +++ b/crate/multiworld/src/frontend.rs @@ -27,6 +27,7 @@ pub const PROTOCOL_VERSION: u8 = 8; pub enum Kind { Dummy, EverDrive, + Flashcart, BizHawk, Pj64V3, Pj64V4, @@ -37,6 +38,7 @@ impl Kind { match self { Self::Dummy => false, Self::EverDrive => true, + Self::Flashcart => true, Self::BizHawk => cfg!(any(target_os = "linux", target_os = "windows")), Self::Pj64V3 => cfg!(target_os = "windows"), Self::Pj64V4 => false, // hide until Project64 version 4 is released @@ -49,6 +51,7 @@ impl fmt::Display for Kind { match self { Self::Dummy => write!(f, "(no frontend)"), Self::EverDrive => write!(f, "EverDrive"), + Self::Flashcart => write!(f, "Flashcart"), Self::BizHawk => write!(f, "BizHawk"), Self::Pj64V3 | Self::Pj64V4 => write!(f, "Project64"), }