From bbd85a12c568937b564a1f62956141af6c7792be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Rom=C3=A0n?= Date: Mon, 7 Sep 2026 22:37:28 +0200 Subject: [PATCH] Add replay system --- Cargo.lock | 1 + Cargo.toml | 1 + src/args.rs | 10 ++ src/debug_tools.rs | 15 ++- src/lib.rs | 2 + src/match_loading.rs | 24 +++- src/replay.rs | 288 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 src/replay.rs diff --git a/Cargo.lock b/Cargo.lock index e7cb6e1..ac7ca22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3558,6 +3558,7 @@ dependencies = [ "rhai", "ron 0.12.2", "serde", + "serde_json", "strum", "strum_macros", ] diff --git a/Cargo.toml b/Cargo.toml index 7fc7c80..79179b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ bevy-inspector-egui = "0.37.0" bevy_egui = "0.40.0" bevy_ggrs = { git = "https://github.com/gschup/bevy_ggrs" } serde = "1.0.229" +serde_json = "1.0.145" bincode = "1.3.3" gltf = "1.4.1" ron = "0.12.0" diff --git a/src/args.rs b/src/args.rs index 465df30..cb38f21 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,5 +1,6 @@ use bevy::prelude::*; use clap::Parser; +use std::path::PathBuf; #[derive(Parser, Debug, Clone, Resource)] #[command(version, about, long_about = None)] @@ -12,4 +13,13 @@ pub struct Args { pub check_distance: usize, #[clap(long, default_value_t = 1)] pub players: usize, + /// Record a replay to this path. + #[clap(long, conflicts_with = "play_replay")] + pub record_replay: Option, + /// Play a replay from this path. + #[clap(long, conflicts_with = "record_replay")] + pub play_replay: Option, + /// Replay output format. JSON is useful for inspection; binary is compact. + #[clap(long, value_enum, default_value_t = crate::replay::ReplayFormat::Binary)] + pub replay_format: crate::replay::ReplayFormat, } diff --git a/src/debug_tools.rs b/src/debug_tools.rs index d006402..35e9c1f 100644 --- a/src/debug_tools.rs +++ b/src/debug_tools.rs @@ -11,7 +11,7 @@ use bevy_ggrs::RollbackFrameRate; use enum_cycling::EnumCycle; use enum_cycling_derive::EnumCycle; -use crate::player::Player; +use crate::{player::Player, replay::StopReplayRecording}; pub const PLAYER_DEBUG_ROW_COUNT: usize = 6; @@ -118,6 +118,9 @@ enum DebugFlag { #[derive(Clone, Copy)] enum DebugMenuEntry { + StopRecording { + label: &'static str, + }, Toggle { label: &'static str, flag: DebugFlag, @@ -189,6 +192,9 @@ const RENDERING_MENU: &[DebugMenuEntry] = &[ ]; const ROOT_MENU: &[DebugMenuEntry] = &[ + DebugMenuEntry::StopRecording { + label: "Stop replay recording", + }, DebugMenuEntry::Submenu { label: "Fighter", entries: FIGHTER_MENU, @@ -446,6 +452,7 @@ fn handle_palette_input( DebugMenuPage::Static(entries) => { if let Some(entry) = entries.get(settings.selected()) { match entry { + DebugMenuEntry::StopRecording { .. } => {} DebugMenuEntry::MotionSampling { .. } => settings.cycle_motion_sampling(), DebugMenuEntry::GameSpeed { .. } => { if direction > 0 { @@ -489,6 +496,9 @@ fn handle_palette_input( if gamepad.just_pressed(GamepadButton::South) { match page { DebugMenuPage::Static(entries) if page_len != 0 => match entries[settings.selected()] { + DebugMenuEntry::StopRecording { .. } => { + commands.insert_resource(StopReplayRecording); + } DebugMenuEntry::Toggle { flag, .. } => { settings.toggle_flag(flag); commands.queue(SaveSettingsDeferred(Duration::from_secs_f32(0.1))); @@ -550,6 +560,9 @@ fn draw_palette( for (index, entry) in entries.iter().enumerate() { let marker = if index == selected { ">" } else { " " }; match entry { + DebugMenuEntry::StopRecording { label } => { + ui.label(format!("{marker} {label}")); + } DebugMenuEntry::Toggle { label, flag } => { let state = if settings.flag(*flag) { "on" } else { "off" }; ui.label(format!("{marker} {label}: {state}")); diff --git a/src/lib.rs b/src/lib.rs index 4c28975..40087a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ pub mod match_loading; mod math; pub mod netcode; pub mod player; +pub mod replay; pub mod schedule; pub mod scripting; pub mod stage; @@ -84,6 +85,7 @@ impl Plugin for GamePlugin { netcode::FighterNetcodePlugin::default(), GameplaySchedulePlugin, input::FighterInputPlugin, + replay::ReplayPlugin, debug_tools::DebugToolsPlugin, fighter::FighterPlugin, fighter::baked_animation::BakedAnimationPlugin, diff --git a/src/match_loading.rs b/src/match_loading.rs index de6ebb4..bd4b1c3 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use bevy::{asset::UntypedHandle, gltf::Gltf, prelude::*}; use bevy_asset_loader::prelude::*; use bevy_ggrs::prelude::Session; +use serde::{Deserialize, Serialize}; use crate::{ AppState, @@ -27,7 +28,7 @@ use crate::{ }, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct MatchPlayer { pub handle: usize, pub fighter: FighterId, @@ -146,7 +147,28 @@ pub fn initiate_default_match( mut commands: Commands, args: Res, mut next_state: ResMut>, + completed: Option>, ) { + if completed.is_some() { + return; + } + if let Some(path) = &args.play_replay { + match crate::replay::load(path) { + Ok(replay) => { + let request = PendingMatch { + stage: replay.match_config.stage, + players: replay.match_config.players.clone(), + }; + commands.insert_resource(crate::replay::LoadedReplay(replay)); + initiate_match(&mut commands, &mut next_state, request); + } + Err(error) => { + error!("Replay load failed: {error}"); + next_state.set(AppState::MatchLoadFailed); + } + } + return; + } let players = (0..args.players) .map(|handle| MatchPlayer { handle, diff --git a/src/replay.rs b/src/replay.rs new file mode 100644 index 0000000..e6e4b1c --- /dev/null +++ b/src/replay.rs @@ -0,0 +1,288 @@ +//! Deterministic input recording and playback. + +use std::{collections::BTreeMap, fs, path::{Path, PathBuf}}; + +use bevy::prelude::*; +use bevy_ggrs::{prelude::*, PlayerInputs, RollbackFrameCount, SaveWorld}; +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; + +use crate::{args::Args, input::FighterInputFrame, match_loading::{MatchPlayer, PendingMatch}, netcode::GGRSCfg, schedule::GameplaySet, AppState}; + +const MAGIC: &[u8; 8] = b"SPRPLY01"; +pub const REPLAY_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum ReplayFormat { + #[default] + Binary, + Json, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Replay { + pub replay_version: u32, + pub game_version: String, + pub match_config: ReplayMatch, + pub frames: Vec, + pub checksums: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ReplayMatch { + pub stage: crate::stage::manifest::StageId, + pub players: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ReplayFrame { + pub frame: i32, + pub inputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReplayChecksum { + pub frame: i32, + pub checksum: u128, +} + +#[derive(Resource)] +pub struct LoadedReplay(pub Replay); + +#[derive(Resource)] +pub struct ReplayComplete; + +#[derive(Resource, Default)] +pub struct StopReplayRecording; + +enum ReplayMode { + Record { path: PathBuf, format: ReplayFormat }, + Playback { replay: Replay, mismatch: bool }, +} + +#[derive(Resource)] +struct ReplayRuntime { + mode: ReplayMode, + frames: BTreeMap>, + checksums: BTreeMap, + finished: bool, +} + +pub struct ReplayPlugin; + +impl Plugin for ReplayPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(AppState::InMatch), start_replay) + .add_systems( + GgrsSchedule, + replay_inputs + .in_set(GameplaySet::Input) + .before(crate::input::buffer::postprocess_input), + ) + .add_systems(SaveWorld, record_checksum.after(SaveWorldSystems::Checksum).before(SaveWorldSystems::Snapshot)) + .add_systems(OnExit(AppState::InMatch), finish_recording) + .add_systems(Update, stop_recording.run_if(in_state(AppState::InMatch))) + .add_systems(Update, finish_playback.run_if(in_state(AppState::InMatch))); + } +} + +pub fn load(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| format!("cannot read replay {}: {error}", path.display()))?; + let replay = if bytes.starts_with(MAGIC) { + if bytes.len() < MAGIC.len() + 4 { + return Err("replay binary header is truncated".into()); + } + let version = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + if version != REPLAY_VERSION { + return Err(format!("unsupported replay version {version}")); + } + bincode::deserialize(&bytes[12..]).map_err(|error| format!("invalid binary replay: {error}"))? + } else { + serde_json::from_slice(&bytes).map_err(|error| format!("invalid JSON replay: {error}"))? + }; + validate(&replay)?; + Ok(replay) +} + +pub fn save(path: &Path, format: ReplayFormat, replay: &Replay) -> Result<(), String> { + validate(replay)?; + let bytes = match format { + ReplayFormat::Binary => { + let mut bytes = MAGIC.to_vec(); + bytes.extend(REPLAY_VERSION.to_le_bytes()); + bytes.extend(bincode::serialize(replay).map_err(|error| format!("cannot encode replay: {error}"))?); + bytes + } + ReplayFormat::Json => serde_json::to_vec_pretty(replay).map_err(|error| format!("cannot encode replay JSON: {error}"))?, + }; + let temporary = path.with_extension("replay.tmp"); + fs::write(&temporary, bytes).map_err(|error| format!("cannot write replay: {error}"))?; + fs::rename(&temporary, path).map_err(|error| format!("cannot finalize replay: {error}")) +} + +fn validate(replay: &Replay) -> Result<(), String> { + if replay.replay_version != REPLAY_VERSION { + return Err(format!("unsupported replay version {}", replay.replay_version)); + } + if replay.players_are_invalid() { + return Err("replay player handles must cover 0..player_count".into()); + } + if replay.frames.is_empty() { + return Err("replay must contain at least one frame".into()); + } + if replay.frames.windows(2).any(|frames| frames[0].frame >= frames[1].frame) { + return Err("replay frames must be strictly ordered".into()); + } + if replay.frames.iter().any(|frame| frame.inputs.len() != replay.match_config.players.len()) { + return Err("replay frame input count does not match player count".into()); + } + Ok(()) +} + +impl Replay { + fn players_are_invalid(&self) -> bool { + let mut handles: Vec<_> = self.match_config.players.iter().map(|player| player.handle).collect(); + handles.sort_unstable(); + handles != (0..handles.len()).collect::>() + } +} + +fn start_replay(mut commands: Commands, args: Res, loaded: Option>) { + let mode = if let Some(replay) = loaded { + ReplayMode::Playback { replay: replay.0.clone(), mismatch: false } + } else if let Some(path) = args.record_replay.clone() { + ReplayMode::Record { path, format: args.replay_format } + } else { + return; + }; + commands.insert_resource(ReplayRuntime { mode, frames: BTreeMap::new(), checksums: BTreeMap::new(), finished: false }); +} + +fn replay_inputs(mut inputs: ResMut>, frame: Res, runtime: Option>) { + let Some(mut runtime) = runtime else { return }; + match &mut runtime.mode { + ReplayMode::Record { .. } => { + runtime.frames.insert(frame.0, inputs.iter().map(|(input, _)| *input).collect()); + } + ReplayMode::Playback { replay, mismatch } if !*mismatch => { + let Some(recorded) = replay.frames.iter().find(|recorded| recorded.frame == frame.0) else { + error!("Replay has no input for frame {}", frame.0); + *mismatch = true; + return; + }; + for ((input, _), recorded) in inputs.iter_mut().zip(&recorded.inputs) { + *input = *recorded; + } + } + ReplayMode::Playback { .. } => {} + } +} + +fn record_checksum(frame: Res, checksum: Res, runtime: Option>) { + let Some(mut runtime) = runtime else { return }; + if matches!(&runtime.mode, ReplayMode::Record { .. }) { + runtime.checksums.insert(frame.0, checksum.0); + } else if let ReplayMode::Playback { replay, mismatch } = &mut runtime.mode { + if let Some(expected) = replay.checksums.iter().find(|value| value.frame == frame.0).map(|value| value.checksum) { + if expected != checksum.0 { + error!("Replay desync at frame {}: expected {:X}, got {:X}", frame.0, expected, checksum.0); + *mismatch = true; + } + } + } +} + +fn stop_recording( + mut commands: Commands, + stop_request: Option>, + runtime: Option>, + request: Option>, +) { + if stop_request.is_none() { + return; + } + commands.remove_resource::(); + finish_recording_inner(&mut commands, runtime, request); +} + +fn finish_recording(mut commands: Commands, runtime: Option>, request: Option>) { + finish_recording_inner(&mut commands, runtime, request); +} + +fn finish_recording_inner( + commands: &mut Commands, + runtime: Option>, + request: Option>, +) { + let Some(mut runtime) = runtime else { return }; + let ReplayMode::Record { path, format } = &runtime.mode else { return }; + if runtime.finished || runtime.frames.is_empty() { return; } + let Some(request) = request else { return }; + let path = path.clone(); + let format = *format; + let replay = Replay { + replay_version: REPLAY_VERSION, + game_version: env!("CARGO_PKG_VERSION").into(), + match_config: ReplayMatch { stage: request.stage, players: request.players.clone() }, + frames: runtime.frames.iter().map(|(&frame, inputs)| ReplayFrame { frame, inputs: inputs.clone() }).collect(), + checksums: runtime.checksums.iter().map(|(&frame, &checksum)| ReplayChecksum { frame, checksum }).collect(), + }; + if let Err(error) = save(&path, format, &replay) { error!("Failed to save replay: {error}"); } + runtime.finished = true; + commands.remove_resource::(); +} + +fn finish_playback(mut commands: Commands, mut next_state: ResMut>, frame: Res, runtime: Option>) { + let Some(runtime) = runtime else { return }; + let ReplayMode::Playback { replay, mismatch } = &runtime.mode else { return }; + if *mismatch || replay.frames.last().is_some_and(|last| frame.0 >= last.frame) { + commands.remove_resource::(); + commands.remove_resource::(); + commands.insert_resource(ReplayComplete); + next_state.set(AppState::Idle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{fighter::FighterId, math::{int::FGi32, vec::FGVec2}, match_loading::MatchPlayer, stage::manifest::StageId}; + + fn sample() -> Replay { + Replay { + replay_version: REPLAY_VERSION, + game_version: "test".into(), + match_config: ReplayMatch { + stage: StageId::TestStage, + players: vec![MatchPlayer { handle: 0, fighter: FighterId::TestFighter }], + }, + frames: vec![ReplayFrame { + frame: 1, + inputs: vec![FighterInputFrame { movement: FGVec2::new(FGi32::from_num(1), FGi32::ZERO), ..default() }], + }], + checksums: vec![ReplayChecksum { frame: 1, checksum: 42 }], + } + } + + #[test] + fn binary_and_json_round_trip() { + let replay = sample(); + let directory = std::env::temp_dir(); + let binary = directory.join(format!("game-test-replay-{}.bin", std::process::id())); + let json = directory.join(format!("game-test-replay-{}.json", std::process::id())); + + save(&binary, ReplayFormat::Binary, &replay).unwrap(); + save(&json, ReplayFormat::Json, &replay).unwrap(); + assert_eq!(load(&binary).unwrap(), replay); + assert_eq!(load(&json).unwrap(), replay); + let _ = fs::remove_file(binary); + let _ = fs::remove_file(json); + } + + #[test] + fn malformed_frame_input_count_is_rejected() { + let mut replay = sample(); + replay.frames[0].inputs.clear(); + assert!(validate(&replay).is_err()); + } +}