├── .gitignore ├── .gitmodules ├── Cargo.toml ├── LICENSE.md ├── README.md ├── rust-toolchain.toml └── src ├── buttplug.rs ├── cli └── main.rs ├── config.rs ├── csgo.rs ├── default_script.rhai ├── lib.rs ├── script.rs ├── scripts ├── kills_and_assists.rhai └── shooting.rhai ├── timer_thread.rs └── ui └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | natvis 4 | .vscode 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/csgo-gsi"] 2 | path = deps/csgo-gsi 3 | url = git@github.com:gloss-click/csgo-gsi.git 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cs2_buttplug" 3 | version = "0.11.0" 4 | authors = ["gloss-click", "hornycactus "] 5 | edition = "2018" 6 | description = "Buttplug.io mod for CS2" 7 | readme = "README.md" 8 | homepage = "https://github.com/gloss-click/cs2-buttplug" 9 | repository = "https://github.com/gloss-click/cs2-buttplug" 10 | license-file = "LICENSE.md" 11 | publish = false 12 | 13 | [lib] 14 | name = "cs2_buttplug" 15 | path = "src/lib.rs" 16 | 17 | [[bin]] 18 | name = "cs2_buttplug_cli" 19 | path = "src/cli/main.rs" 20 | 21 | [[bin]] 22 | name = "cs2_buttplug_ui" 23 | path = "src/ui/main.rs" 24 | 25 | [dependencies] 26 | buttplug = "7.1.11" 27 | csgo-gsi = { features = ["rhai"], git = 'https://github.com/gloss-click/csgo-gsi.git' } 28 | rhai = { version = "0.18.3", features = ["sync"] } 29 | futures = "0.3.30" 30 | toml = "0.8.8" 31 | serde = { version = "1.0.195", features = ["derive"] } 32 | anyhow = "1.0.79" 33 | tokio = { version = "1.35.1", features = ["full"] } 34 | tokio-stream = { version = "0.1.14", features = ["sync"] } 35 | log = "0.4.20" 36 | pretty_env_logger = "0.5.0" 37 | git-transport = { version = "0.2.1", features = ["http-client-curl"] } 38 | semver = "0.10.0" 39 | socket2 = "0.3.16" 40 | eframe = { version = "0.25.0", features = ["persistence"] } 41 | rfd = "0.13.0" -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The Fuck Around and Find Out License, version 0.2 2 | 3 | ## Purpose 4 | 5 | This license gives everyone as much permission to work with 6 | this software as possible, while protecting contributors 7 | from liability, and ensuring this software is used 8 | ethically. 9 | 10 | ## Acceptance 11 | 12 | In order to receive this license, you must agree to its 13 | rules. The rules of this license are both obligations 14 | under that agreement and conditions to your license. 15 | You must not do anything with this software that triggers 16 | a rule that you cannot or will not follow. 17 | 18 | ## Copyright 19 | 20 | Each contributor licenses you to do everything with this 21 | software that would otherwise infringe that contributor's 22 | copyright in it. 23 | 24 | ## Ethics 25 | 26 | This software must be used for Good, not Evil, as 27 | determined by the primary contributors to the software. 28 | 29 | ## Excuse 30 | 31 | If anyone notifies you in writing that you have not 32 | complied with [Ethics](#ethics), you can keep your 33 | license by taking all practical steps to comply within 30 34 | days after the notice. If you do not do so, your license 35 | ends immediately. 36 | 37 | ## Patent 38 | 39 | Each contributor licenses you to do everything with this 40 | software that would otherwise infringe any patent claims 41 | they can license or become able to license. 42 | 43 | ## Reliability 44 | 45 | No contributor can revoke this license. 46 | 47 | ## No Liability 48 | 49 | ***As far as the law allows, this software comes as is, 50 | without any warranty or condition, and no contributor 51 | will be liable to anyone for any damages related to this 52 | software or this license, under any kind of legal claim.*** 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cs2-buttplug: The bomb has been planted 2 | 3 | I've updated [horny_cactus's CS:GO/GSI/Buttplug interface](https://sr.ht/~hornycactus/CrotchStimGetOff) for the latest versions of everything and hopefully made running it a bit more simple, adding a small UI as well as the existing CLI app. 4 | I am not cool enough to think up a CS2 pun with the sheer power of 'Crotch-Stim: Get Off' so I haven't tried. 5 | I've tried to preserve as much of the original readme as possible (all license terms etc persist) but have updated the relevant parts. 6 | This update depends on a patched version of the csgo-gsi crate that I have included as a submodule. 7 | 8 | ![csbp 0 8 0](https://github.com/gloss-click/cs2-buttplug/assets/157309744/d87295f2-b41a-4c12-b09d-489594e38625) 9 | 10 | ## Will it get me banned? 11 | 12 | cs2-buttplug takes advantage of the CS2 Game State Integration (GSI) used for external display of scores and other information at tournaments and live events. GSI is an official interface provided by Valve and does not provide any information that could allow a competitive advantage. While there's no reason to expect that running this software alone will result in your being banned from CS2, Valve have ultimate discretion over what they allow to interact with their game. 13 | 14 | ## Important ethical disclaimer 15 | 16 | This software is intended for risk-aware, consensual sexual enjoyment by all. 17 | CS2 is a game with matchmaking that can put you in matches with whoever the hell. 18 | Don't use this in matchmaking or generic community servers. 19 | Use it in botmatches, or on servers set up for the purpose of horny and populated by adults who know what they're getting into. 20 | 21 | ## Usage 22 | To use the CLI: 23 | 1. Build this repo. 24 | - Clone this repository, update the submodules, grab a recent version of Rust, run `cargo build --release`. 25 | 2. Run Intiface Central and start the server. 26 | 3. Run `target/release/cs2-buttplug-cli.exe`. 27 | - It'll create default config and script files in the same folder you put the program itself into and give you a chance to edit them before it starts running. 28 | - The CS2 scripts folder will need to be writable by the executable. 29 | 4. Play CS2 and have fun things happen! 30 | 31 | To use the UI: 32 | 1. Build this repo. 33 | - Clone this repository, update the submodules, grab a recent version of Rust, run `cargo build --release`. 34 | 2. Run Intiface Central and start the server. 35 | 3. Run `target/release/cs2-buttplug-ui.exe`. 36 | - Use the UI to navigate to your CS2 scripts dir (typically `C:\Program Files (x86)\Steam\steamapps\common\Counter-Strike Global Offensive\game\csgo\cfg`) 37 | - This folder will need to be writable by the executable. 38 | 4. Click 'Launch' 39 | 5. Play CS2 and have fun things happen! 40 | 41 | ## Configuration 42 | 43 | For the CLI, a file like this will be created in `cs2-buttplug-cli.toml`. 44 | 45 | ```toml 46 | # specify your Intiface server, 47 | # or leave it out to connect to ws://127.0.0.1:12345/ (the default address of an Intiface install) 48 | buttplug_server_url = 'ws://127.0.0.1:12345/' 49 | 50 | # the port number to use for the CS2 integration server. 51 | # can be anything that isn't already in use on your computer 52 | cs_integration_port = 42069 53 | 54 | # path to cs2 on your machine - this is the default so if you have it installed elsewhere set that location here. 55 | cs_script_dir = 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Counter-Strike Global Offensive\\game\\csgo\\cfg' 56 | ``` 57 | 58 | ## Scripting 59 | 60 | The plugin gets its 'game logic' from a [Rhai](https://schungx.github.io/rhai/) script that is copied into the directory with the executable. 61 | 62 | The default script looks like this: 63 | ```rhai 64 | let current_weapon = "none"; 65 | let x = -1; 66 | let falloff = 0.0; 67 | 68 | let kills = 0; 69 | let assists = 0; 70 | 71 | let steam_id = ""; 72 | 73 | fn handle_update(update) { 74 | if update.player != () { 75 | if steam_id == "" { 76 | steam_id = update.player.steam_id; 77 | } 78 | if steam_id == update.player.steam_id { 79 | if update.player.match_stats != () { 80 | if kills < update.player.match_stats.kills { 81 | let kdiff = (update.player.match_stats.kills - kills); 82 | if kdiff > 5 { 83 | kdiff = 5; 84 | } 85 | for i in range(0, kdiff) { 86 | vibrate_index(kills.to_float()/50.0, 1.0, (kills + i) % 2); 87 | linear(500, 1.0); 88 | sleep(0.5); 89 | linear(500, 0.0); 90 | } 91 | } 92 | 93 | kills = update.player.match_stats.kills; 94 | 95 | if assists < update.player.match_stats.assists { 96 | vibrate(0.15, 1.0); 97 | linear(500, 0.5); 98 | } 99 | 100 | assists = update.player.match_stats.assists; 101 | 102 | if update.player.match_stats.kills == 0 { 103 | kills = 0; 104 | } 105 | if update.player.match_stats.assists == 0 { 106 | assists = 0; 107 | } 108 | } 109 | } 110 | } 111 | } 112 | ``` 113 | 114 | It'll give you a steadily escalating buzz each time you get kills/assists over the course of a round. 115 | 116 | Your script needs to define a function called `handle_update`. 117 | It'll be called with [an `Update` object](https://docs.rs/csgo-gsi/0.3.0/csgo_gsi/update/struct.Update.html) whenever CS:GO sends some new data. 118 | All those optional values are translated into Rhai as either the value itself if it exists or the unit `()` if there's no value. 119 | That's why the example script checks if things are equal to `()`. 120 | All the enums defined by `csgo-gsi` can be `.to_string()`ed and tested against string values, like the default script does for `WeaponState`. 121 | All the maps get translated into Rhai maps; several of those should probably just be arrays in the first place, so go poke the `csgo-gsi` author about that if you need reasonable access to that data. 122 | 123 | Command functions currently implemented are: 124 | - `vibrate(strength, duration in seconds)` 125 | - `vibrate_index(speed, duration in seconds, index of motor)` 126 | - `stop()` 127 | - `linear(duration in milliseconds, position in percent from 0.0 to 1.0)` 128 | - `sleep(duration in seconds);` 129 | 130 | Feel free to file a github issue for any problems you have and I'll try to take a look. 131 | 132 | ## License 133 | 134 | This software is released under the [Fuck Around and Find Out License version 0.2](https://git.sr.ht/~boringcactus/fafol/tree/master/LICENSE-v0.2.md). 135 | The `csgo-gsi` library that this uses is released under the [Anti-Capitalist Software License version 1.4](https://anticapitalist.software/). 136 | 137 | ## Changelog 138 | 139 | v0.8.0 - 2024-06-02 140 | - Add log panel to gui 141 | - List devices in GUI and allow enabling and disabling them 142 | - Automatically launch client on start 143 | - Break up core nest of futures a little bit for more interactivity in future 144 | 145 | v0.7.0 - 2024-04-15 146 | - Resolves an issue where device discovery events could arrive before we were monitoring for them. 147 | 148 | v0.6.0 - 2024-03-14 149 | - Updates from CS faster to be more responsive and allows multiple time periods to overlap, as well as adding basic support for devices with multiple facets. 150 | 151 | v0.5.0 - 2024-02-01 152 | - Swaps the gotham web server out for the warp web server in csgo-gsi, as gotham did not like being restarted once it had started to process requests. 153 | - Code complexity reduction too, probably at the cost of resiliance against bad requests. 154 | 155 | v0.4.0 - 2024-01-22 156 | - Updated for CS2/Buttplug 7+ 157 | - Removed the updater and CS2 directory-finding code for the sake of code simplicity. 158 | - Added an intermediate thread that can reprocess events from the Rhai script. 159 | - Added an egui-based UI. 160 | 161 | v0.1.0 - 2020-09-23 162 | - write the damn thing 163 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.77.2" -------------------------------------------------------------------------------- /src/buttplug.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use anyhow::{Context, Error}; 4 | use buttplug::{ 5 | client::{ 6 | ButtplugClient, 7 | ButtplugClientEvent, device::ScalarValueCommand, device::LinearCommand 8 | }, 9 | core::connector::{ButtplugRemoteClientConnector, ButtplugWebsocketClientTransport}, 10 | core::message::serializer::ButtplugClientJSONSerializer, 11 | util::async_manager 12 | }; 13 | use futures::{future::RemoteHandle, StreamExt}; 14 | use tokio::sync::broadcast; 15 | 16 | use crate::CloseEvent; 17 | 18 | #[derive(Clone)] 19 | pub enum ClientEvent { 20 | DeviceFound(String, u32), 21 | DeviceLost(String, u32) 22 | } 23 | 24 | #[derive(Clone)] 25 | pub enum GuiEvent { 26 | DeviceToggle(u32, bool), 27 | None, 28 | } 29 | 30 | #[derive(Copy, Clone)] 31 | pub enum BPCommand { 32 | Vibrate(f64), 33 | VibrateIndex(f64, u32), 34 | Linear(u32, f64), 35 | Stop 36 | } 37 | 38 | 39 | async fn run_buttplug_catch( 40 | client_send: Option>, 41 | gui_receive: Option>, 42 | close_receive: broadcast::Receiver, 43 | client: ButtplugClient, 44 | transport: ButtplugWebsocketClientTransport, 45 | rx: broadcast::Receiver, 46 | ) { 47 | let err = run_buttplug(client_send, gui_receive, close_receive, client, transport, rx).await; 48 | if let Err(err) = err { 49 | error!("Buttplug thread error: {}", err); 50 | } 51 | } 52 | 53 | async fn run_buttplug( 54 | client_send: Option>, 55 | gui_receive: Option>, 56 | close_receive: broadcast::Receiver, 57 | client: ButtplugClient, 58 | transport: ButtplugWebsocketClientTransport, 59 | rx: broadcast::Receiver, 60 | ) -> Result<(), Error> { 61 | info!("Launched buttplug.io thread"); 62 | 63 | let recv = tokio_stream::wrappers::BroadcastStream::new(rx); 64 | let close_recv = tokio_stream::wrappers::BroadcastStream::new(close_receive); 65 | 66 | let connector = ButtplugRemoteClientConnector::::new(transport); 67 | 68 | 69 | info!("Starting buttplug.io client"); 70 | 71 | enum Event { 72 | Buttplug(ButtplugClientEvent), 73 | Command(BPCommand), 74 | GuiCommand(GuiEvent), 75 | CloseCommand, 76 | } 77 | 78 | let merge_bp_and_commands = tokio_stream::StreamExt::merge( 79 | client.event_stream().map(Event::Buttplug), 80 | recv.map(|ev| { 81 | Event::Command(match ev { 82 | Ok(ev) => ev, 83 | Err(_) => BPCommand::Stop, // stop on error 84 | }) 85 | }), 86 | ); 87 | 88 | let merge_bp_and_commands_and_close = tokio_stream::StreamExt::merge( 89 | merge_bp_and_commands, 90 | close_recv.map(|_| Event::CloseCommand), 91 | ); 92 | 93 | // couldn't find a more elegant way of doing this 94 | let (_, dummy_recv) = tokio::sync::broadcast::channel(1); 95 | let gui_receive_stream = tokio_stream::wrappers::BroadcastStream::new(gui_receive.unwrap_or(dummy_recv)); 96 | 97 | let mut merge_bp_and_commands_and_close_and_gui = tokio_stream::StreamExt::merge( 98 | merge_bp_and_commands_and_close, 99 | gui_receive_stream.map(|ev| { 100 | Event::GuiCommand(match ev { 101 | Ok(ev) => ev, 102 | Err(_) => GuiEvent::None, // stop on error 103 | }) 104 | }), 105 | ); 106 | 107 | client.connect(connector).await?; 108 | client.start_scanning().await.context("Couldn't start buttplug.io device scan")?; 109 | 110 | let mut enabled_devices = HashSet::new(); 111 | 112 | while let Some(event) = merge_bp_and_commands_and_close_and_gui.next().await { 113 | match event { 114 | Event::Buttplug(ButtplugClientEvent::DeviceAdded(dev)) => { 115 | if let Some(ref client_send) = client_send { 116 | match client_send.send(ClientEvent::DeviceFound(dev.name().clone(), dev.index())) { 117 | Ok(_) => {}, 118 | Err(e) => error!("Error sending client event: {}", e), 119 | } 120 | } 121 | info!("Intiface: Device added: {}", dev.name()); 122 | debug!("Device {} capabilities: vibrate: {}, linear: {}", dev.name(), 123 | !dev.vibrate_attributes().is_empty(), 124 | !dev.linear_attributes().is_empty()); 125 | enabled_devices.insert(dev.index()); 126 | } 127 | Event::Buttplug(ButtplugClientEvent::DeviceRemoved(dev)) => { 128 | if let Some(ref client_send) = client_send { 129 | match client_send.send(ClientEvent::DeviceLost(dev.name().clone(), dev.index())) { 130 | Ok(_) => {}, 131 | Err(e) => error!("Error sending client event: {}", e), 132 | } 133 | } 134 | info!("Intiface: Device removed: {}", dev.name()); 135 | enabled_devices.remove(&dev.index()); 136 | } 137 | Event::Buttplug(ButtplugClientEvent::ServerDisconnect) => { 138 | info!("Intiface: server disconnected, shutting down."); 139 | break; // we're done 140 | } 141 | Event::Buttplug(ButtplugClientEvent::ServerConnect) => { 142 | info!("Intiface: connected to server."); 143 | } 144 | Event::Buttplug(ButtplugClientEvent::Error(e)) => { 145 | info!("Intiface: error {}", e); 146 | } 147 | Event::Buttplug(ButtplugClientEvent::PingTimeout) => { 148 | info!("Intiface: server not responding to ping."); 149 | } 150 | Event::Buttplug(ButtplugClientEvent::ScanningFinished) => { 151 | info!("Intiface: scanning complete."); 152 | } 153 | Event::Command(command) => { 154 | match command { 155 | BPCommand::Vibrate(speed) => { 156 | for device in client.devices() { 157 | if enabled_devices.contains(&device.index()) { 158 | if device.vibrate_attributes().is_empty() { 159 | debug!("Device {} does not support vibration attributes, skipping", device.name()); 160 | continue; 161 | } 162 | device.vibrate(&ScalarValueCommand::ScalarValue(speed.min(1.0))) 163 | .await 164 | .context("Couldn't send Vibrate command")?; 165 | } 166 | } 167 | }, 168 | BPCommand::VibrateIndex(speed, index) => { 169 | for device in client.devices() { 170 | if enabled_devices.contains(&device.index()) { 171 | let vibrate_len = device.vibrate_attributes().len() as u32; 172 | 173 | if vibrate_len == 0 { // Prevent underflow: Check if the device has any vibration attributes before performing subtraction 174 | debug!("Device {} does not support vibration attributes, skipping", device.name()); 175 | continue; 176 | } 177 | 178 | let nindex = index.min(vibrate_len - 1); // Ensure index is within bounds 179 | info!("Setting speed {} on index {} on device {}", speed, nindex, &device.name()); 180 | 181 | let map = HashMap::from([(nindex, speed.min(1.0))]); 182 | device.vibrate(&ScalarValueCommand::ScalarValueMap(map)).await.context("Couldn't send VibrateIndex command")?; 183 | } 184 | } 185 | }, 186 | BPCommand::Linear(duration, position) => { 187 | for device in client.devices() { 188 | if enabled_devices.contains(&device.index()) { 189 | if device.linear_attributes().is_empty() { // Ensure the device has linear attributes before proceeding 190 | debug!("Device {} does not support linear movement, skipping", device.name()); 191 | continue; 192 | } 193 | 194 | let max_index = device.linear_attributes().len() as u32; // Safe handling: check if the device has at least one linear attribute 195 | 196 | if max_index > 0 { // Prevent underflow: Making sure we don't subtract from zero 197 | let nindex = position.min((max_index - 1) as f64); 198 | info!("Setting linear position {} on device {}", position, device.name()); 199 | let map = HashMap::from([(nindex as u32, (duration as u32, position.min(1.0)))]); 200 | device.linear(&LinearCommand::LinearMap(map)) 201 | .await 202 | .context("Couldn't send Linear command")?; 203 | } else { 204 | debug!("Device {} does not support linear movement, skipping", device.name()); 205 | } 206 | } 207 | } 208 | }, 209 | BPCommand::Stop => { 210 | for device in client.devices() { 211 | if enabled_devices.contains(&device.index()) { 212 | if !device.vibrate_attributes().is_empty() { 213 | device.vibrate(&ScalarValueCommand::ScalarValue(0.0)) 214 | .await 215 | .context("Couldn't send Stop command for vibration")?; 216 | } 217 | if !device.linear_attributes().is_empty() { 218 | let map = HashMap::from([(0, (0, 0.0))]); 219 | device.linear(&LinearCommand::LinearMap(map)) 220 | .await 221 | .context("Couldn't send Stop command for linear movement")?; 222 | } 223 | } 224 | } 225 | } 226 | } 227 | }, 228 | Event::CloseCommand => { 229 | info!("Buttplug thread asked to quit"); 230 | if client.connected() { 231 | client.disconnect().await.expect("Failed to disconnect from buttplug"); 232 | } 233 | }, 234 | Event::GuiCommand(GuiEvent::DeviceToggle(index, checked)) => { 235 | info!("Device {} enabled {}", index, checked); 236 | match checked { 237 | true => { 238 | enabled_devices.insert(index); 239 | }, 240 | false => { 241 | enabled_devices.remove(&index); 242 | } 243 | } 244 | } 245 | Event::GuiCommand(GuiEvent::None) => { 246 | info!("GUI command"); 247 | } 248 | } 249 | } 250 | 251 | // And now we're done! 252 | info!("Exiting buttplug thread"); 253 | Ok(()) 254 | } 255 | 256 | pub fn spawn_run_thread(close_receive: broadcast::Receiver, connect_url: &String, client_send: Option>, gui_receive: Option>) -> Result<(broadcast::Sender, RemoteHandle<()>), Error> { 257 | info!("Spawning buttplug thread"); 258 | let client_name = "CS2 integration"; 259 | let (send, recv) = broadcast::channel(5); 260 | let bpclient = ButtplugClient::new(client_name); 261 | 262 | let transport = if connect_url.starts_with("wss://") { 263 | ButtplugWebsocketClientTransport::new_secure_connector(&connect_url, false) 264 | } else { 265 | ButtplugWebsocketClientTransport::new_insecure_connector(&connect_url) 266 | }; 267 | 268 | let handle = async_manager::spawn_with_handle(run_buttplug_catch( 269 | client_send, 270 | gui_receive, 271 | close_receive, 272 | bpclient, 273 | transport, 274 | recv, 275 | ))?; 276 | 277 | Ok((send, handle)) 278 | } 279 | -------------------------------------------------------------------------------- /src/cli/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate log; 3 | 4 | use std::{env::current_exe, fs::{read_to_string, write}, io::stdin}; 5 | 6 | use anyhow::{Context, Error}; 7 | 8 | use cs2_buttplug::{async_main_with_buttplug, config::Config}; 9 | 10 | pub fn wait_for_enter() { 11 | let mut ignored = String::new(); 12 | let _ = stdin().read_line(&mut ignored); 13 | } 14 | 15 | pub fn get_config() -> Result { 16 | let exe_path = current_exe().context("couldn't get path of cs2-buttplug.exe")?; 17 | let config_path = exe_path.with_extension("toml"); 18 | if !config_path.exists() { 19 | info!("Creating config file {} with default settings, go look over those settings and then come back and press Enter", config_path.display()); 20 | let default_config = Config::default(); 21 | let default_config = toml::to_string_pretty(&default_config).context("couldn't build default config file")?; 22 | write(&config_path, default_config).context("couldn't save default config file")?; 23 | } 24 | 25 | let config_text = read_to_string(&config_path).context("couldn't read config file")?; 26 | toml::from_str(&config_text).context("couldn't parse config file") 27 | } 28 | 29 | fn inner_main() -> Result<(), Error> { 30 | info!("This is cs2-buttplug (cli), v{}, original author hornycactus (https://cactus.sexy)", env!("CARGO_PKG_VERSION")); 31 | 32 | let config = get_config()?; 33 | 34 | let tokio_runtime = tokio::runtime::Runtime::new().unwrap(); 35 | let handle = tokio_runtime.handle().clone(); 36 | let (close_send, _close_receive) = tokio::sync::broadcast::channel(64); 37 | let _result: core::result::Result<(), Error> = tokio_runtime.block_on(async { 38 | async_main_with_buttplug(config, handle, close_send).await 39 | }); 40 | 41 | Ok(()) 42 | } 43 | 44 | fn main() { 45 | pretty_env_logger::formatted_builder() 46 | .filter_level(log::LevelFilter::Warn) 47 | .filter(Some("cs2_buttplug"), log::LevelFilter::max()) 48 | .filter(Some("cs2_buttplug_cli"), log::LevelFilter::max()) 49 | .filter(Some("csgo_gsi"), log::LevelFilter::max()) 50 | .init(); 51 | 52 | if let Err(error) = inner_main() { 53 | error!("{}", error); 54 | println!("\nPress Enter to exit..."); 55 | wait_for_enter(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Deserialize}; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Serialize, Deserialize, Clone)] 5 | pub struct Config { 6 | pub buttplug_server_url: String, 7 | pub cs_integration_port: u16, 8 | pub cs_script_dir: Option, 9 | pub rhai_script_path: Option 10 | } 11 | 12 | impl Default for Config { 13 | fn default() -> Self { 14 | Config { 15 | buttplug_server_url: "ws://127.0.0.1:12345".to_string(), 16 | cs_integration_port: 42069, 17 | cs_script_dir: None, 18 | rhai_script_path: None 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/csgo.rs: -------------------------------------------------------------------------------- 1 | use std::{time::Duration, path::PathBuf}; 2 | 3 | use csgo_gsi::{Error, GSIConfigBuilder, GSIServer, Subscription}; 4 | 5 | pub fn build_server(port: u16, game_path: PathBuf) -> Result { 6 | let config = GSIConfigBuilder::new("cs2-bp") 7 | .subscribe_multiple(Subscription::UNRESTRICTED) 8 | .throttle(Duration::from_millis(50)) 9 | .buffer(Duration::from_millis(50)) 10 | .heartbeat(Duration::from_millis(1000)) 11 | .build(); 12 | 13 | let mut server = GSIServer::new(config, port); 14 | 15 | server.install(game_path)?; 16 | 17 | Ok(server) 18 | } 19 | -------------------------------------------------------------------------------- /src/default_script.rhai: -------------------------------------------------------------------------------- 1 | let kills = 0; 2 | let assists = 0; 3 | 4 | fn handle_update(update, is_player) { 5 | if is_player && update.player != () { 6 | if update.player.match_stats != () { 7 | if kills < update.player.match_stats.kills { 8 | let kdiff = (update.player.match_stats.kills - kills); 9 | if kdiff > 5 { 10 | kdiff = 5; 11 | } 12 | for i in range(0, kdiff) { 13 | vibrate_index(kills.to_float()/50.0, 1.0, (kills + i) % 2); 14 | linear(500, 1.0); 15 | sleep(0.5); 16 | linear(500, 0.0); 17 | } 18 | } 19 | 20 | kills = update.player.match_stats.kills; 21 | 22 | if assists < update.player.match_stats.assists { 23 | vibrate(0.15, 1.0); 24 | linear(500, 0.5); 25 | } 26 | 27 | assists = update.player.match_stats.assists; 28 | 29 | if update.player.match_stats.kills == 0 { 30 | kills = 0; 31 | } 32 | if update.player.match_stats.assists == 0 { 33 | assists = 0; 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate log; 3 | 4 | use std::{ 5 | future::IntoFuture, path::PathBuf, str::FromStr 6 | }; 7 | 8 | use anyhow::{Context, Error}; 9 | use buttplug::BPCommand; 10 | use csgo_gsi::GSIServer; 11 | use futures::{future::RemoteHandle, TryFutureExt}; 12 | 13 | mod buttplug; 14 | pub use buttplug::{ClientEvent, GuiEvent}; 15 | pub mod config; 16 | mod csgo; 17 | mod script; 18 | mod timer_thread; 19 | 20 | use config::Config; 21 | use tokio::{runtime::Handle, sync::broadcast, task::JoinHandle}; 22 | 23 | use crate::timer_thread::ScriptCommand; 24 | 25 | const DEFAULT_GAME_DIR: &str = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Counter-Strike Global Offensive\\game\\csgo\\cfg"; 26 | 27 | pub type CloseEvent = csgo_gsi::CloseEvent; 28 | 29 | pub async fn spawn_buttplug_client(buttplug_server_url: &String, close_receive: broadcast::Receiver, client_send: Option>, gui_receive: Option>) -> Result<(broadcast::Sender, RemoteHandle<()>), Error> { 30 | buttplug::spawn_run_thread(close_receive, &buttplug_server_url, client_send, gui_receive).context("couldn't start buttplug client") 31 | } 32 | 33 | fn spawn_tasks(config: &Config, tokio_handle: Handle, buttplug_send: broadcast::Sender) -> Result<(GSIServer, broadcast::Sender, JoinHandle<()>, script::ScriptHost), Error> { 34 | let gsi_server = csgo::build_server(config.cs_integration_port, match &config.cs_script_dir { Some(dir) => dir.clone(), None => PathBuf::from_str(DEFAULT_GAME_DIR).unwrap() }) 35 | .map_err(|err| anyhow::anyhow!("{}", err)).context("couldn't set up CS integration server")?; 36 | let (event_proc_send, event_proc_thread) = timer_thread::spawn_timer_thread(tokio_handle, buttplug_send)?; 37 | let script_host = script::ScriptHost::new(event_proc_send.clone(), &config.rhai_script_path).context("couldn't start script host")?; 38 | Ok((gsi_server, event_proc_send, event_proc_thread, script_host)) 39 | } 40 | 41 | pub async fn async_main_with_buttplug(config: Config, tokio_handle: Handle, close_send: broadcast::Sender) -> Result<(), Error> { 42 | let tokio_handle2 = tokio_handle.clone(); 43 | let (buttplug_send, buttplug_thread) = spawn_buttplug_client(&config.buttplug_server_url, close_send.subscribe(), None, None).await.unwrap(); 44 | let main_handle = async_main(config, tokio_handle, close_send, buttplug_send, None::); 45 | tokio_handle2.block_on(std::future::IntoFuture::into_future(main_handle)).unwrap(); 46 | tokio_handle2.block_on(buttplug_thread.into_future()); 47 | Ok(()) 48 | } 49 | 50 | pub async fn async_main(config: Config, tokio_handle: Handle, close_send: broadcast::Sender, buttplug_send: broadcast::Sender, gsi_listener: Option) -> Result<(), Error> { 51 | match spawn_tasks(&config, tokio_handle.clone(), buttplug_send) { 52 | Ok((mut gsi_server, event_proc_send, event_proc_thread, mut script_host)) => { 53 | gsi_server.add_listener(move |update| script_host.handle_update(update)); 54 | 55 | if let Some(mut listener) = gsi_listener { 56 | gsi_server.add_listener(move |update| listener(update)); 57 | } 58 | 59 | let gsi_close_event_receiver = close_send.subscribe(); 60 | let mut main_close_event_receiver = close_send.subscribe(); 61 | 62 | let gsi_task_handle = gsi_server.run(tokio_handle.clone(), gsi_close_event_receiver).map_err(|err| anyhow::anyhow!("{}", err)); 63 | 64 | let gsi_tokio_handle = tokio_handle.clone(); 65 | let gsi_exit_handle = tokio_handle.spawn_blocking(move || gsi_tokio_handle.block_on(gsi_task_handle)); 66 | 67 | info!("Initialised; waiting for exit"); 68 | main_close_event_receiver.recv().await.expect("Critical: Crashed waiting for close event."); 69 | info!("Sending close event"); 70 | close_send.send(CloseEvent{}).expect("Critical: Crashed sending close event."); 71 | info!("Closing GSI thread"); 72 | gsi_exit_handle.await.unwrap().expect("Critical: Crashed stopping GSI server."); 73 | info!("Closing event processing thread."); 74 | event_proc_send.send(ScriptCommand::Close).expect("Critical: Crashed sending close to event processing thread."); 75 | 76 | event_proc_thread.await.expect("Critical: failed to join timer thread"); 77 | }, 78 | Err(e) => info!("Error : {}", e.to_string()), 79 | }; 80 | 81 | Ok(()) 82 | } 83 | -------------------------------------------------------------------------------- /src/script.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env::current_exe, 3 | fs::{metadata, write}, 4 | path::PathBuf, 5 | time::SystemTime, 6 | }; 7 | 8 | use anyhow::{Context, Error}; 9 | use csgo_gsi::update::{player::WeaponState, CSGOPackage, Update}; 10 | use rhai::{AST, Engine, packages::Package, Scope, RegisterFn}; 11 | use tokio::sync::broadcast; 12 | use crate::timer_thread::ScriptCommand; 13 | 14 | pub struct ScriptHost { 15 | engine: Engine, 16 | scope: Scope<'static>, 17 | ast: AST, 18 | script_path: PathBuf, 19 | last_modified: SystemTime, 20 | } 21 | 22 | impl ScriptHost { 23 | pub fn new(send: broadcast::Sender, config_script_path: &Option) -> Result { 24 | let vsend = send.clone(); 25 | let visend = send.clone(); 26 | let ssend = send.clone(); 27 | let mut engine = Engine::new(); 28 | 29 | engine.set_max_call_levels(200); 30 | engine.set_max_expr_depths(200, 200); 31 | engine.set_max_operations(20000); 32 | 33 | engine.load_package(CSGOPackage::new().get()); 34 | engine.register_fn("vibrate", move |speed: f64, time: f64| { 35 | let result = vsend.send(ScriptCommand::VibrateFor(speed, time)); 36 | if let Err(err) = result { 37 | error!("Error sending command from script to buttplug: {}", err); 38 | } 39 | }); 40 | engine.register_fn("vibrate_index", move |speed: f64, time: f64, index: i64| { 41 | let result = visend.send(ScriptCommand::VibrateForWithIndex(speed, time, index as u32)); 42 | if let Err(err) = result { 43 | error!("Error sending command from script to buttplug: {}", err); 44 | } 45 | }); 46 | engine.register_fn("linear", move |duration: i64, position: f64| { 47 | let result = send.send(ScriptCommand::Linear(duration as u32, position)); 48 | if let Err(err) = result { 49 | error!("Error sending Linear command: {}", err); 50 | } 51 | }); 52 | engine.register_fn("stop", move || { 53 | let result = ssend.send(ScriptCommand::Stop); 54 | if let Err(err) = result { 55 | error!("Error sending command from script to buttplug: {}", err); 56 | } 57 | }); 58 | engine.register_fn("sleep", |time: f64| { 59 | std::thread::sleep(std::time::Duration::from_secs_f64(time)); 60 | }); 61 | engine.register_fn("log", move |msg: String| { 62 | info!("Script: {}", msg); 63 | }); 64 | let mut scope = Scope::new(); 65 | 66 | let exe_path = current_exe().context("couldn't get path of cs2-buttplug executable")?; 67 | let script_path = match config_script_path { 68 | Some(path) => path.clone(), 69 | None => exe_path.with_extension("rhai"), 70 | }; 71 | 72 | if !script_path.exists() { 73 | info!("Creating default script {} with default settings, go look over that script and then come back and press Enter", script_path.display()); 74 | write(&script_path, include_str!("default_script.rhai")).context("couldn't save default config file")?; 75 | } 76 | 77 | let mtime = metadata(&script_path) 78 | .and_then(|x| x.modified()) 79 | .context("couldn't check modification time of script")?; 80 | 81 | info!("Using script: {}", script_path.to_str().unwrap()); 82 | 83 | let ast = engine.compile_file_with_scope(&mut scope, script_path.clone()) 84 | .map_err(|err| { 85 | error!("{}", err); 86 | anyhow::anyhow!("{}", err) 87 | })?; 88 | // if there's some global state or on-boot handling, make sure it runs 89 | engine.consume_ast_with_scope(&mut scope, &ast) 90 | .map_err(|err| anyhow::anyhow!("{}", err))?; 91 | Ok(Self { 92 | engine, 93 | scope, 94 | ast, 95 | script_path, 96 | last_modified: mtime, 97 | }) 98 | } 99 | 100 | pub fn handle_update(&mut self, update: &Update) { 101 | let needs_rebuild = metadata(&self.script_path) 102 | .and_then(|x| x.modified()) 103 | .map(|mtime| (mtime > self.last_modified, mtime)); 104 | if let Ok((true, mtime)) = needs_rebuild { 105 | info!("Noticed live script change, rebuilding..."); 106 | let compile_result = self.engine.compile_file_with_scope(&mut self.scope, self.script_path.clone()) 107 | .and_then(|ast| { 108 | self.engine.consume_ast_with_scope(&mut self.scope, &ast)?; 109 | self.ast = ast; 110 | Ok(()) 111 | }); 112 | if let Err(e) = compile_result { 113 | error!("Error when rebuilding script: {}", e); 114 | } 115 | self.last_modified = mtime; 116 | } 117 | let is_player = update.player.as_ref().map_or_else(|| "".to_string(), |a| a.steam_id.clone()) == update.provider.as_ref().map_or_else(|| "".to_string(), |a| a.steam_id.clone()); 118 | let result = self.engine.call_fn::<(Update, bool), ()>(&mut self.scope, &mut self.ast, "handle_update", (update.clone(), is_player)); 119 | if let Err(e) = result { 120 | error!("Error when handling update: {}", e); 121 | }; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/scripts/kills_and_assists.rhai: -------------------------------------------------------------------------------- 1 | let kills = 0; 2 | let assists = 0; 3 | 4 | fn handle_update(update, is_player) { 5 | if is_player && update.player != () { 6 | if update.player.match_stats != () { 7 | if kills < update.player.match_stats.kills { 8 | let kdiff = (update.player.match_stats.kills - kills); 9 | if kdiff > 5 { 10 | kdiff = 5; 11 | } 12 | for i in range(0, kdiff) { 13 | vibrate_index(kills.to_float()/50.0, 1.0, (kills + i) % 2); 14 | linear(500, 1.0); 15 | sleep(0.5); 16 | linear(500, 0.0); 17 | } 18 | } 19 | 20 | kills = update.player.match_stats.kills; 21 | 22 | if assists < update.player.match_stats.assists { 23 | vibrate(0.15, 1.0); 24 | linear(500, 0.5); 25 | } 26 | 27 | assists = update.player.match_stats.assists; 28 | 29 | if update.player.match_stats.kills == 0 { 30 | kills = 0; 31 | } 32 | if update.player.match_stats.assists == 0 { 33 | assists = 0; 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/scripts/shooting.rhai: -------------------------------------------------------------------------------- 1 | let ammo_last = 0; 2 | 3 | fn handle_update(update, is_player) { 4 | if is_player && update.player != () { 5 | for weapon in update.player.weapons.values() { 6 | if weapon.state.to_string() == "Active" { 7 | if weapon.ammo_clip != () { 8 | if ammo_last != weapon.ammo_clip { 9 | vibrate(1.0 - (weapon.ammo_clip.to_float() / weapon.ammo_clip_max.to_float()), 0.5); 10 | } 11 | ammo_last = weapon.ammo_clip; 12 | } 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/timer_thread.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::{BTreeMap}, time::{SystemTime, Duration}}; 2 | 3 | use anyhow::Error; 4 | use tokio::{sync::broadcast, task::JoinHandle, runtime::Handle}; 5 | use crate::buttplug::BPCommand; 6 | 7 | 8 | #[derive(Copy, Clone, Debug)] 9 | pub enum ScriptCommand { 10 | VibrateFor(f64, f64), 11 | VibrateForWithIndex(f64, f64, u32), 12 | Linear(u32, f64), // Duration in milliseconds, position as a percentage 13 | Stop, 14 | Close, 15 | } 16 | 17 | pub fn spawn_timer_thread(h: Handle, send: broadcast::Sender) -> Result<(broadcast::Sender, JoinHandle<()>), Error> { 18 | let (nsend, nrecv) = broadcast::channel(64); 19 | 20 | let handle = tokio::task::spawn_blocking(move || { 21 | h.block_on(timer_thread(send, nrecv)).expect("Timer thread dispatch failed."); 22 | }); 23 | 24 | Ok((nsend, handle)) 25 | } 26 | 27 | async fn timer_thread(send: broadcast::Sender, mut recv: broadcast::Receiver) -> Result<(), Error> { 28 | let mut pqueue = BTreeMap::new(); 29 | 30 | info!("started event process thread"); 31 | let mut close = false; 32 | loop { 33 | 34 | let mut enqueue_func = |msg, pqueue: &mut BTreeMap| { 35 | let timestamp = SystemTime::now(); 36 | match msg { 37 | ScriptCommand::VibrateFor(strength, time) => { 38 | pqueue.insert(timestamp, BPCommand::Vibrate(strength)); 39 | pqueue.insert(timestamp + Duration::from_secs_f64(time), BPCommand::Stop); 40 | }, 41 | ScriptCommand::VibrateForWithIndex(strength, time, index) => { 42 | pqueue.insert(timestamp, BPCommand::VibrateIndex(strength, index)); 43 | pqueue.insert(timestamp + Duration::from_secs_f64(time), BPCommand::VibrateIndex(0.0, index)); 44 | }, 45 | ScriptCommand::Linear(duration, position) => { 46 | pqueue.insert(timestamp, BPCommand::Linear(duration, position)); 47 | }, 48 | ScriptCommand::Stop => { 49 | pqueue.insert(timestamp, BPCommand::Stop); 50 | }, 51 | ScriptCommand::Close => { 52 | close = true; 53 | }, 54 | } 55 | }; 56 | 57 | if !pqueue.is_empty() { 58 | if let Ok(msg) = recv.try_recv() { 59 | enqueue_func(msg, &mut pqueue); 60 | }; 61 | } else { 62 | if let Ok(msg) = recv.recv().await { 63 | enqueue_func(msg, &mut pqueue); 64 | } 65 | } 66 | 67 | if let Some(front) = pqueue.first_entry() { 68 | if front.key() < &SystemTime::now() { 69 | let (_key, value) = front.remove_entry(); 70 | 71 | match send.send(value) { 72 | Ok(_) => {}, 73 | Err(e) => { 74 | info!("raw command send error {}", e); 75 | break; 76 | }, 77 | } 78 | } 79 | } 80 | 81 | if close { 82 | break; 83 | } 84 | 85 | }; 86 | info!("ending event process thread"); 87 | Ok(()) 88 | } -------------------------------------------------------------------------------- /src/ui/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 2 | 3 | 4 | use std::collections::HashMap; 5 | use std::future::IntoFuture; 6 | use std::path::{Path, PathBuf}; 7 | 8 | use std::str::FromStr; 9 | use std::sync::{Arc, Mutex}; 10 | use std::time::Duration; 11 | use csgo_gsi::update::player::WeaponState; 12 | use eframe::egui; 13 | use cs2_buttplug::config::Config; 14 | use cs2_buttplug::{async_main, spawn_buttplug_client, ClientEvent, CloseEvent, GuiEvent}; 15 | use futures::future::RemoteHandle; 16 | use futures::FutureExt; 17 | use log::error; 18 | use tokio::runtime::Runtime; 19 | use std::{fs, io}; 20 | use pretty_env_logger::env_logger::Target; 21 | 22 | const CS2_BP_DIR_PATH: &str = "CS2_BP_DIR_PATH"; 23 | const CS2_BP_PORT: &str = "CS2_BP_PORT"; 24 | const CS2_BP_INTIFACE_ADDR: &str = "CS2_BP_INTIFACE_ADDR"; 25 | 26 | // This struct is used as an adaptor, it implements io::Write and forwards the buffer to a mpsc::Sender 27 | struct GuiLog { 28 | //log_buffer: &'a mut Vec, 29 | logs: Arc>>, 30 | } 31 | 32 | impl io::Write for GuiLog { 33 | // On write we forward each u8 of the buffer to the sender and return the length of the buffer 34 | fn write(&mut self, buf: &[u8]) -> io::Result { 35 | 36 | self.logs.lock().unwrap().push(std::str::from_utf8(buf).unwrap().to_string()); 37 | std::io::stdout().write(buf); 38 | Ok(buf.len()) 39 | } 40 | 41 | fn flush(&mut self) -> io::Result<()> { 42 | Ok(()) 43 | } 44 | } 45 | 46 | fn main() -> Result<(), eframe::Error> { 47 | let logs: Arc>> = Arc::new(Mutex::new(Vec::new())); 48 | 49 | pretty_env_logger::formatted_builder() 50 | .filter_level(log::LevelFilter::Warn) 51 | .filter(Some("cs2_buttplug"), log::LevelFilter::max()) 52 | .filter(Some("cs2_buttplug_ui"), log::LevelFilter::max()) 53 | .filter(Some("csgo_gsi"), log::LevelFilter::max()) 54 | .target(Target::Pipe(Box::new(GuiLog{logs: logs.clone()}))) 55 | .init(); 56 | 57 | let options = eframe::NativeOptions { 58 | viewport: egui::ViewportBuilder::default().with_inner_size([640.0, 640.0]), 59 | ..Default::default() 60 | }; 61 | 62 | eframe::run_native( 63 | "cs2-buttplug-ui", 64 | options, 65 | Box::new(|cc| { 66 | let mut config = Config::default(); 67 | 68 | if let Some(storage) = cc.storage { 69 | if let Some(csgo_dir_path) = storage.get_string(CS2_BP_DIR_PATH) { 70 | if let Ok(path) = PathBuf::from_str(csgo_dir_path.as_ref()) { 71 | config.cs_script_dir = Some(path); 72 | } 73 | } 74 | if let Some(port) = storage.get_string(CS2_BP_PORT) { 75 | if let Ok(port) = port.parse::() { 76 | config.cs_integration_port = port; 77 | } 78 | } 79 | if let Some(addr) = storage.get_string(CS2_BP_INTIFACE_ADDR) { 80 | config.buttplug_server_url = addr; 81 | } 82 | } 83 | 84 | Box::::new(CsButtplugUi::new(config, logs)) 85 | }), 86 | ) 87 | } 88 | 89 | #[derive(PartialEq, Eq, Clone, Copy)] 90 | enum Status { 91 | Absent, 92 | Disabled, 93 | Enabled 94 | } 95 | 96 | struct CsButtplugUi { 97 | tokio_runtime: Runtime, 98 | main_handle: Option>, 99 | buttplug_thread: Option>, 100 | config: Config, 101 | close_send: tokio::sync::broadcast::Sender, 102 | _close_receive: tokio::sync::broadcast::Receiver, 103 | 104 | client_events: Option>, 105 | gui_send: Option>, 106 | 107 | editor_port: String, 108 | 109 | logs: Arc>>, 110 | 111 | devices: HashMap, 112 | 113 | autostarted: bool, 114 | 115 | last_update: Arc>>, 116 | } 117 | 118 | impl CsButtplugUi { 119 | fn new(config: Config, logs: Arc>>) -> Self { 120 | let (close_send, _close_receive) = tokio::sync::broadcast::channel(64); 121 | 122 | Self { 123 | tokio_runtime: Runtime::new().unwrap(), 124 | main_handle: None, 125 | buttplug_thread: None, 126 | config: config, 127 | close_send, _close_receive, 128 | client_events: None, 129 | gui_send: None, 130 | editor_port: "".to_string(), 131 | logs, 132 | devices: HashMap::new(), 133 | autostarted: false, 134 | last_update: Arc::new(Mutex::new(None)), 135 | } 136 | } 137 | } 138 | 139 | impl CsButtplugUi { 140 | pub fn launch_main(&mut self) { 141 | // TODO: end previous 142 | let handle = self.tokio_runtime.handle().clone(); 143 | let config = self.config.clone(); 144 | let sender = self.close_send.clone(); 145 | 146 | let (client_send, client_receive) = tokio::sync::broadcast::channel(64); 147 | self.client_events = Some(client_receive); 148 | let (gui_send, gui_receive) = tokio::sync::broadcast::channel(64); 149 | self.gui_send = Some(gui_send); 150 | 151 | //let mut gsi_update = None; 152 | 153 | let bp_future = async { 154 | spawn_buttplug_client(&config.buttplug_server_url, sender.subscribe(), Some(client_send), Some(gui_receive)).await.unwrap() 155 | }; 156 | 157 | let (buttplug_send, buttplug_thread) = self.tokio_runtime.block_on(bp_future); 158 | 159 | let mutex = self.last_update.clone(); 160 | 161 | let func = move |gsi_update: &csgo_gsi::Update| { 162 | let mut locked_update = mutex.lock().unwrap(); 163 | *locked_update = Some(gsi_update.clone()); 164 | }; 165 | 166 | let (main_future, main_handle) = async move { 167 | let _ = async_main(config, handle, sender, buttplug_send, Some(func) /* None:: */ /* here */).await; 168 | }.remote_handle(); 169 | 170 | let tokio_handle = self.tokio_runtime.handle().clone(); 171 | self.tokio_runtime.spawn_blocking(move || { 172 | tokio_handle.block_on(main_future); 173 | }); 174 | 175 | self.buttplug_thread = Some(buttplug_thread); 176 | self.main_handle = Some(main_handle); 177 | } 178 | 179 | pub fn close(&mut self) { 180 | self.close_send.send(CloseEvent{}).expect("Failed to send close event."); 181 | if let Some(handle) = &mut self.buttplug_thread { 182 | self.tokio_runtime.block_on(handle.into_future()); 183 | } 184 | if let Some(handle) = &mut self.main_handle { 185 | self.tokio_runtime.block_on(handle.into_future()); 186 | } 187 | self.main_handle = None; 188 | } 189 | } 190 | 191 | impl eframe::App for CsButtplugUi { 192 | fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { 193 | 194 | if let Some(handle) = &mut self.main_handle { 195 | if let Some(_) = handle.now_or_never() { 196 | self.main_handle = None; 197 | } 198 | } 199 | 200 | ctx.request_repaint_after(Duration::from_millis(10)); 201 | 202 | if let Some(ref mut client_events) = self.client_events { 203 | while let Ok(ev) = client_events.try_recv() { 204 | match ev { 205 | ClientEvent::DeviceFound(name, index) => { 206 | self.devices.insert(index, (name, Status::Enabled)); 207 | }, 208 | ClientEvent::DeviceLost(name, index) => { 209 | self.devices.insert(index, (name, Status::Absent)); 210 | }, 211 | } 212 | } 213 | } 214 | 215 | egui::CentralPanel::default().show(ctx, |mut ui| { 216 | ui.heading("CS2 Buttplug.io integration"); 217 | ui.add_space(5.0); 218 | 219 | ui.horizontal(|ui| { 220 | ui.spacing_mut().item_spacing.x = 0.0; 221 | ui.label(format!("This is cs2-buttplug (gui), v{}, github: ", env!("CARGO_PKG_VERSION"))); 222 | ui.hyperlink("https://github.com/gloss-click/cs2-buttplug"); 223 | }); 224 | ui.label("Credit to original author hornycactus."); 225 | 226 | ui.separator(); 227 | ui.add_space(5.0); 228 | 229 | ui.vertical(|ui| { 230 | ui.heading("Settings"); 231 | 232 | ui.set_enabled(match self.main_handle { 233 | Some(_) => false, 234 | None => true, 235 | }); 236 | ui.label("Path to CS2 integration script dir:"); 237 | ui.horizontal(|ui| { 238 | ui.label(match &self.config.cs_script_dir { 239 | Some(path) => path.display().to_string(), 240 | None => "[None]".to_string(), 241 | }); 242 | 243 | if ui.button("Browse").clicked() { 244 | if let Some(path) = rfd::FileDialog::new().pick_folder() { 245 | if let Some(storage) = frame.storage_mut() { 246 | storage.set_string(CS2_BP_DIR_PATH, path.display().to_string()) 247 | } 248 | 249 | self.config.cs_script_dir = Some(path); 250 | } 251 | } 252 | }); 253 | 254 | ui.add_space(5.0); 255 | 256 | ui.label(format!("Intiface URL (default: ws://127.0.0.1:12345):")); 257 | if ui.text_edit_singleline(&mut self.config.buttplug_server_url).changed() { 258 | if self.config.buttplug_server_url.len() > 0 { 259 | if let Some(storage) = frame.storage_mut() { 260 | storage.set_string(CS2_BP_INTIFACE_ADDR, self.config.buttplug_server_url.clone()); 261 | } 262 | } 263 | } 264 | 265 | ui.add_space(5.0); 266 | 267 | ui.label(format!("Advanced: GSI integration port (default 42069): {}", self.config.cs_integration_port)); 268 | 269 | if ui.text_edit_singleline(&mut self.editor_port).changed() { 270 | match self.editor_port.parse::() { 271 | Ok(i) => { 272 | if let Some(storage) = frame.storage_mut() { 273 | storage.set_string(CS2_BP_PORT, i.to_string()); 274 | } 275 | 276 | self.config.cs_integration_port = i; 277 | }, 278 | Err(_) => {}, 279 | } 280 | } 281 | }); 282 | 283 | ui.add_space(5.0); 284 | ui.separator(); 285 | 286 | ui.heading("Start"); 287 | 288 | 289 | ui.horizontal(|ui| { 290 | if self.main_handle.is_none() { 291 | if ui.button("Launch").clicked() { 292 | self.launch_main(); 293 | } 294 | if !self.autostarted { 295 | self.launch_main(); 296 | self.autostarted = true 297 | } 298 | } else { 299 | if ui.button("Relaunch").clicked() { 300 | self.close(); 301 | self.launch_main(); 302 | } 303 | } 304 | 305 | ui.set_enabled(match self.main_handle { 306 | Some(_) => true, 307 | None => false, 308 | }); 309 | 310 | if ui.button("Stop").clicked() { 311 | self.close(); 312 | } 313 | }); 314 | 315 | if ctx.input(|i| i.viewport().close_requested()) { 316 | self.close(); 317 | } 318 | ui.separator(); 319 | 320 | ui.heading("Scripts"); 321 | 322 | let script_dir_path = Path::new("./scripts"); 323 | 324 | if !script_dir_path.exists() { 325 | match fs::create_dir(script_dir_path) { 326 | Ok(_) => { 327 | let script_path = script_dir_path.join("default_script.rhai"); 328 | match fs::write(&script_path, include_str!("../default_script.rhai")) { 329 | Ok(_) => {}, 330 | Err(e) => { 331 | ui.label(e.to_string()); 332 | } 333 | } 334 | }, 335 | Err(e) => { 336 | ui.label(e.to_string()); 337 | } 338 | } 339 | } 340 | 341 | let paths = fs::read_dir(script_dir_path); 342 | match paths { 343 | Ok(paths) => { 344 | for path in paths { 345 | if let Ok(path) = path { 346 | if (ui.selectable_value(&mut self.config.rhai_script_path, Some(path.path()), path.file_name().to_str().unwrap())).clicked() { 347 | self.close(); 348 | self.launch_main(); 349 | } 350 | } 351 | } 352 | }, 353 | Err(e) => { 354 | ui.label(e.to_string()); 355 | } 356 | } 357 | 358 | ui.separator(); 359 | 360 | ui.heading("Devices"); 361 | 362 | fn status_to_label(status: &Status) -> &str { 363 | match status { 364 | Status::Enabled => "Enabled", 365 | Status::Disabled => "Disabled", 366 | Status::Absent => "Disconnected" 367 | } 368 | } 369 | 370 | for (index, (name, ref mut status)) in self.devices.iter_mut() { 371 | ui.set_enabled(match status { 372 | Status::Absent => false, 373 | _ => true, 374 | }); 375 | 376 | let mut checked = *status == Status::Enabled; 377 | if (ui.checkbox(&mut checked, format!("{}: {}", name, status_to_label(status)))).changed() { 378 | *status = match checked { 379 | true => Status::Enabled, 380 | false => Status::Disabled, 381 | }; 382 | if let Some(ref mut gui_send) = self.gui_send { 383 | match gui_send.send(GuiEvent::DeviceToggle(*index, checked)) { 384 | Ok(_) => {}, 385 | Err(e) => error!("Error sending GUI command: {}", e), 386 | } 387 | } 388 | } 389 | } 390 | ui.separator(); 391 | 392 | ui.heading("Game State"); 393 | 394 | egui::CollapsingHeader::new("Values").show(&mut ui, |mut ui| { 395 | let locked_update = self.last_update.lock().unwrap(); 396 | 397 | if let Some(ref update) = *locked_update { 398 | if let Some(ref player) = update.player { 399 | ui.label(format!("name: {} team: {} spectating: {}", 400 | player.name, 401 | match player.team { None => "", Some(csgo_gsi::update::Team::T) => "Terrorists", Some(csgo_gsi::update::Team::CT) => "CTs"}, 402 | match &update.provider { 403 | Some(provider) => match &provider.steam_id { 404 | i if *i == player.steam_id => "no", 405 | _ => "yes", 406 | }, 407 | _ => "unknown", 408 | } 409 | )); 410 | if let Some(ref match_stats) = player.match_stats { 411 | ui.label(format!("kills: {} deaths: {} assists: {} mvps: {}", match_stats.kills, match_stats.deaths, match_stats.assists, match_stats.mvps)); 412 | } 413 | if let Some(ref state) = player.state { 414 | ui.label(format!("health: {} armour: {} helmet: {} cash: {}", state.health, state.armor, match state.helmet { true => "yes", false => "no" }, state.money)); 415 | ui.label(format!("flashed: {} smoked: {} burning: {} defuse kit: {}", 416 | match state.flashed { i if i > 0 => "yes", _ => "no" }, 417 | match state.smoked { i if i > 0 => "yes", _ => "no" }, 418 | match state.burning { i if i > 0 => "yes", _ => "no" }, match state.defuse_kit { Some(true) => "yes", _ => "no" } 419 | )); 420 | } 421 | 422 | for (_name, weapon) in player.weapons.iter() { 423 | match weapon.state { 424 | WeaponState::Active => { 425 | if let (Some(clip), Some(mmax)) = (weapon.ammo_clip, weapon.ammo_clip_max) { 426 | ui.label(format!("active weapon: {} ammo: {}/{}", weapon.name, clip, mmax)); 427 | } 428 | }, 429 | _ => {}, 430 | } 431 | } 432 | } 433 | } 434 | }); 435 | 436 | ui.separator(); 437 | 438 | egui::CollapsingHeader::new("Log").show(&mut ui, |mut ui| { 439 | egui::ScrollArea::new([false, true]).max_height(400.0).stick_to_bottom(true).auto_shrink([false, true]).show(&mut ui, |ui| { 440 | let logs = self.logs.lock().unwrap(); 441 | 442 | let log = logs.concat(); 443 | 444 | ui.add_sized(egui::vec2(650.0, 100.0), egui::TextEdit::multiline(&mut log.as_ref())); 445 | }); 446 | }); 447 | 448 | }); 449 | } 450 | } --------------------------------------------------------------------------------