diff --git a/Cargo.lock b/Cargo.lock index 08e6d22..549fef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,15 @@ version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -952,6 +961,25 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "castaway" version = "0.2.3" @@ -1178,6 +1206,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1187,6 +1221,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -1253,6 +1302,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "der" version = "0.7.9" @@ -1264,6 +1319,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -1381,6 +1456,7 @@ dependencies = [ "anyhow", "azalea", "bevy_app", + "bevy_ecs", "bevy_log", "built", "clap", @@ -1393,7 +1469,10 @@ dependencies = [ "log", "mlua", "ncr", + "parking_lot", + "serde_json", "tokio", + "zip", ] [[package]] @@ -2159,6 +2238,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.26" @@ -2174,6 +2259,16 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + [[package]] name = "matchers" version = "0.1.0" @@ -2381,6 +2476,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-format" version = "0.4.4" @@ -2495,6 +2596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", + "hmac", ] [[package]] @@ -2611,6 +2713,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -3100,6 +3208,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simd_cesu8" version = "1.0.1" @@ -3328,6 +3442,25 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad298b01a40a23aac4580b67e3dbedb7cc8402f3592d7f49469de2ea4aecdd8" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -4050,6 +4183,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerovec" @@ -4072,3 +4219,74 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zip" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b280484c454e74e5fff658bbf7df8fdbe7a07c6b2de4a53def232c15ef138f3a" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap 2.7.1", + "lzma-rs", + "memchr", + "pbkdf2", + "rand", + "sha1", + "thiserror 2.0.11", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.14+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 585ebf4..b7046ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ built = { version = "0", features = ["git2"] } anyhow = "1" azalea = { git = "https://github.com/azalea-rs/azalea.git" } bevy_app = "0" +bevy_ecs = "0" bevy_log = "0" clap = { version = "4", features = ["derive", "string"] } console-subscriber = { version = "0", optional = true } @@ -33,7 +34,10 @@ hyper-util = "0" log = { version = "0" } mlua = { version = "0", features = ["async", "luajit", "send"] } ncr = { version = "0", features = ["cfb8", "ecb", "gcm"] } +parking_lot = "0" +serde_json = "1" tokio = { version = "1", features = ["macros"] } +zip = "2" [features] console-subscriber = ["dep:console-subscriber"] diff --git a/README.md b/README.md index fd019b3..7aae80d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A Minecraft bot with Lua scripting support, written in Rust with [azalea](https: - Pathfinding (from azalea) - Entity and chest interaction - NoChatReports encryption +- Saving ReplayMod recordings ## Usage diff --git a/src/events.rs b/src/events.rs index b794f2b..fefeeec 100644 --- a/src/events.rs +++ b/src/events.rs @@ -2,9 +2,11 @@ use crate::{ State, commands::CommandSource, http::serve, - lua::{self, direction::Direction, player::Player, vec3::Vec3}, + lua::{client, direction::Direction, player::Player, vec3::Vec3}, particle, + replay::Recorder, }; +use anyhow::Context; use azalea::{ brigadier::exceptions::BuiltInExceptions::DispatcherUnknownCommand, prelude::*, protocol::packets::game::ClientboundGamePacket, @@ -12,7 +14,7 @@ use azalea::{ use hyper::{server::conn::http1, service::service_fn}; use hyper_util::rt::TokioIo; use log::{debug, error, info, trace}; -use mlua::{Function, IntoLuaMulti, Table}; +use mlua::{Error, Function, IntoLuaMulti, Table}; use ncr::utils::trim_header; use tokio::net::TcpListener; @@ -167,9 +169,23 @@ pub async fn handle_event(client: Client, event: Event, state: State) -> anyhow: Event::Init => { debug!("received initialize event"); - state.lua.globals().set( + let globals = state.lua.globals(); + let lua_ecs = client.ecs.clone(); + globals.set( + "finish_replay_recording", + state.lua.create_function_mut(move |_, (): ()| { + lua_ecs + .lock() + .remove_resource::() + .context("recording not active") + .map_err(Error::external)? + .finish() + .map_err(Error::external) + })?, + )?; + globals.set( "client", - lua::client::Client { + client::Client { inner: Some(client), }, )?; diff --git a/src/main.rs b/src/main.rs index 31ac80b..914f805 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod events; mod http; mod lua; mod particle; +mod replay; use arguments::Arguments; use azalea::{ @@ -21,7 +22,8 @@ use clap::Parser; use commands::{CommandSource, register}; use futures::lock::Mutex; use futures_locks::RwLock; -use mlua::{Function, Lua}; +use mlua::{Function, Lua, Table}; +use replay::{Recorder, plugin::RecordPlugin}; use std::{ collections::HashMap, env, @@ -37,10 +39,10 @@ type ListenerMap = Arc>>>; #[derive(Default, Clone, Component)] pub struct State { + http_address: Option, lua: Arc, event_listeners: ListenerMap, commands: Arc>>, - http_address: Option, } #[tokio::main] @@ -91,15 +93,33 @@ async fn main() -> anyhow::Result<()> { ..Default::default() }) }; + let record_plugin = RecordPlugin { + recorder: Arc::new(parking_lot::Mutex::new( + if let Ok(options) = globals.get::("ReplayRecordingOptions") + && let Ok(path) = options.get::("path") + { + Some(Recorder::new( + path, + server.clone(), + options + .get::("ignore_compression") + .unwrap_or_default(), + )?) + } else { + None + }, + )), + }; let Err(error) = ClientBuilder::new_without_plugins() .add_plugins(default_plugins) + .add_plugins(record_plugin) .add_plugins(DefaultBotPlugins) .set_handler(events::handle_event) .set_state(State { + http_address: args.http_address, lua: Arc::new(lua), event_listeners, commands: Arc::new(commands), - http_address: args.http_address, }) .start( if username.contains('@') { diff --git a/src/replay/mod.rs b/src/replay/mod.rs new file mode 100644 index 0000000..8debaf5 --- /dev/null +++ b/src/replay/mod.rs @@ -0,0 +1,88 @@ +pub mod plugin; + +use crate::build_info; +use anyhow::Result; +use azalea::{ + buf::AzaleaWriteVar, + prelude::Resource, + protocol::packets::{PROTOCOL_VERSION, ProtocolPacket, VERSION_NAME}, +}; +use serde_json::json; +use std::{ + fs::File, + io::Write, + time::{SystemTime, UNIX_EPOCH}, +}; +use zip::{ZipWriter, write::SimpleFileOptions}; + +#[derive(Resource)] +pub struct Recorder { + zip_writer: ZipWriter, + start_time: u128, + server: String, + ignore_compression: bool, +} + +impl Recorder { + pub fn new(path: String, server: String, ignore_compression: bool) -> Result { + let mut zip_writer = ZipWriter::new( + File::options() + .write(true) + .create(true) + .truncate(true) + .open(path)?, + ); + zip_writer.start_file("recording.tmcpr", SimpleFileOptions::default())?; + Ok(Self { + zip_writer, + start_time: SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(), + server, + ignore_compression, + }) + } + + pub fn finish(mut self) -> Result<()> { + self.zip_writer + .start_file("metaData.json", SimpleFileOptions::default())?; + self.zip_writer.write_all( + json!({ + "singleplayer": false, + "serverName": self.server, + "duration": SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() - self.start_time, + "date": self.start_time, + "mcversion": VERSION_NAME, + "fileFormat": "MCPR", + "fileFormatVersion": 14, + "protocol": PROTOCOL_VERSION, + "generator": build_info::version_formatted(), + }) + .to_string() + .as_bytes(), + )?; + self.zip_writer.finish()?; + + Ok(()) + } + + fn get_timestamp(&self) -> Result<[u8; 4]> { + Ok(TryInto::::try_into( + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() - self.start_time, + )? + .to_be_bytes()) + } + + fn save_raw_packet(&mut self, raw_packet: &[u8]) -> Result<()> { + let mut data = Vec::from(self.get_timestamp()?); + data.extend(TryInto::::try_into(raw_packet.len())?.to_be_bytes()); + data.extend(raw_packet); + self.zip_writer.write_all(&data)?; + Ok(()) + } + + fn save_packet(&mut self, packet: &T) -> Result<()> { + let mut raw_packet = Vec::new(); + packet.id().azalea_write_var(&mut raw_packet)?; + packet.write(&mut raw_packet)?; + self.save_raw_packet(&raw_packet) + } +} diff --git a/src/replay/plugin.rs b/src/replay/plugin.rs new file mode 100644 index 0000000..7500b5b --- /dev/null +++ b/src/replay/plugin.rs @@ -0,0 +1,76 @@ +use super::Recorder; +use azalea::{ + ecs::{event::EventReader, system::Query}, + packet_handling::{ + configuration::ConfigurationEvent, + game::send_packet_events, + login::{LoginPacketEvent, process_packet_events}, + }, + protocol::packets::login::ClientboundLoginPacket, + raw_connection::RawConnection, +}; +use bevy_app::{First, Plugin}; +use bevy_ecs::{schedule::IntoSystemConfigs, system::ResMut}; +use log::error; +use parking_lot::Mutex; +use std::sync::Arc; + +pub struct RecordPlugin { + pub recorder: Arc>>, +} + +impl Plugin for RecordPlugin { + fn build(&self, app: &mut bevy_app::App) { + if let Some(recorder) = self.recorder.lock().take() { + app.insert_resource(recorder); + } + app.add_systems(First, record_login_packets.before(process_packet_events)) + .add_systems(First, record_configuration_packets) + .add_systems(First, record_game_packets.before(send_packet_events)); + } +} + +fn record_login_packets( + recorder: Option>, + mut events: EventReader, +) { + if let Some(mut recorder) = recorder { + for event in events.read() { + if recorder.ignore_compression + && let ClientboundLoginPacket::LoginCompression(_) = *event.packet + { + continue; + } + + if let Err(error) = recorder.save_packet(event.packet.as_ref()) { + error!("failed to record login packet: {error:?}"); + } + } + } +} + +fn record_configuration_packets( + recorder: Option>, + mut events: EventReader, +) { + if let Some(mut recorder) = recorder { + for event in events.read() { + if let Err(error) = recorder.save_packet(&event.packet) { + error!("failed to record configuration packet: {error:?}"); + } + } + } +} + +fn record_game_packets(recorder: Option>, query: Query<&RawConnection>) { + if let Some(mut recorder) = recorder + && let Ok(raw_conn) = query.get_single() + { + let queue = raw_conn.incoming_packet_queue(); + for raw_packet in queue.lock().iter() { + if let Err(error) = recorder.save_raw_packet(raw_packet) { + error!("failed to record game packet: {error:?}"); + } + } + } +}