Formatting
This commit is contained in:
+156
-35
@@ -1,13 +1,24 @@
|
||||
//! Deterministic input recording and playback.
|
||||
|
||||
use std::{collections::BTreeMap, fs, path::{Path, PathBuf}};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_ggrs::{prelude::*, PlayerInputs, RollbackFrameCount, SaveWorld};
|
||||
use bevy_ggrs::{PlayerInputs, RollbackFrameCount, SaveWorld, prelude::*};
|
||||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{args::Args, input::FighterInputFrame, match_loading::{MatchPlayer, PendingMatch}, netcode::GGRSCfg, schedule::GameplaySet, AppState};
|
||||
use crate::{
|
||||
AppState,
|
||||
args::Args,
|
||||
input::FighterInputFrame,
|
||||
match_loading::{MatchPlayer, PendingMatch},
|
||||
netcode::GGRSCfg,
|
||||
schedule::GameplaySet,
|
||||
};
|
||||
|
||||
const MAGIC: &[u8; 8] = b"SPRPLY01";
|
||||
pub const REPLAY_VERSION: u32 = 1;
|
||||
@@ -79,7 +90,12 @@ impl Plugin for ReplayPlugin {
|
||||
.in_set(GameplaySet::Input)
|
||||
.before(crate::input::buffer::postprocess_input),
|
||||
)
|
||||
.add_systems(SaveWorld, record_checksum.after(SaveWorldSystems::Checksum).before(SaveWorldSystems::Snapshot))
|
||||
.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)));
|
||||
@@ -87,7 +103,8 @@ impl Plugin for ReplayPlugin {
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Replay, String> {
|
||||
let bytes = fs::read(path).map_err(|error| format!("cannot read replay {}: {error}", path.display()))?;
|
||||
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());
|
||||
@@ -96,7 +113,8 @@ pub fn load(path: &Path) -> Result<Replay, String> {
|
||||
if version != REPLAY_VERSION {
|
||||
return Err(format!("unsupported replay version {version}"));
|
||||
}
|
||||
bincode::deserialize(&bytes[12..]).map_err(|error| format!("invalid binary replay: {error}"))?
|
||||
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}"))?
|
||||
};
|
||||
@@ -110,10 +128,14 @@ pub fn save(path: &Path, format: ReplayFormat, replay: &Replay) -> Result<(), St
|
||||
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.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}"))?,
|
||||
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}"))?;
|
||||
@@ -122,7 +144,10 @@ pub fn save(path: &Path, format: ReplayFormat, replay: &Replay) -> Result<(), St
|
||||
|
||||
fn validate(replay: &Replay) -> Result<(), String> {
|
||||
if replay.replay_version != REPLAY_VERSION {
|
||||
return Err(format!("unsupported replay version {}", replay.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());
|
||||
@@ -130,10 +155,18 @@ fn validate(replay: &Replay) -> Result<(), String> {
|
||||
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) {
|
||||
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()) {
|
||||
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(())
|
||||
@@ -141,7 +174,12 @@ fn validate(replay: &Replay) -> Result<(), String> {
|
||||
|
||||
impl Replay {
|
||||
fn players_are_invalid(&self) -> bool {
|
||||
let mut handles: Vec<_> = self.match_config.players.iter().map(|player| player.handle).collect();
|
||||
let mut handles: Vec<_> = self
|
||||
.match_config
|
||||
.players
|
||||
.iter()
|
||||
.map(|player| player.handle)
|
||||
.collect();
|
||||
handles.sort_unstable();
|
||||
handles != (0..handles.len()).collect::<Vec<_>>()
|
||||
}
|
||||
@@ -149,23 +187,44 @@ impl Replay {
|
||||
|
||||
fn start_replay(mut commands: Commands, args: Res<Args>, loaded: Option<Res<LoadedReplay>>) {
|
||||
let mode = if let Some(replay) = loaded {
|
||||
ReplayMode::Playback { replay: replay.0.clone(), mismatch: false }
|
||||
ReplayMode::Playback {
|
||||
replay: replay.0.clone(),
|
||||
mismatch: false,
|
||||
}
|
||||
} else if let Some(path) = args.record_replay.clone() {
|
||||
ReplayMode::Record { path, format: args.replay_format }
|
||||
ReplayMode::Record {
|
||||
path,
|
||||
format: args.replay_format,
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
commands.insert_resource(ReplayRuntime { mode, frames: BTreeMap::new(), checksums: BTreeMap::new(), finished: false });
|
||||
commands.insert_resource(ReplayRuntime {
|
||||
mode,
|
||||
frames: BTreeMap::new(),
|
||||
checksums: BTreeMap::new(),
|
||||
finished: false,
|
||||
});
|
||||
}
|
||||
|
||||
fn replay_inputs(mut inputs: ResMut<PlayerInputs<GGRSCfg>>, frame: Res<RollbackFrameCount>, runtime: Option<ResMut<ReplayRuntime>>) {
|
||||
fn replay_inputs(
|
||||
mut inputs: ResMut<PlayerInputs<GGRSCfg>>,
|
||||
frame: Res<RollbackFrameCount>,
|
||||
runtime: Option<ResMut<ReplayRuntime>>,
|
||||
) {
|
||||
let Some(mut runtime) = runtime else { return };
|
||||
match &mut runtime.mode {
|
||||
ReplayMode::Record { .. } => {
|
||||
runtime.frames.insert(frame.0, inputs.iter().map(|(input, _)| *input).collect());
|
||||
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 {
|
||||
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;
|
||||
@@ -178,14 +237,26 @@ fn replay_inputs(mut inputs: ResMut<PlayerInputs<GGRSCfg>>, frame: Res<RollbackF
|
||||
}
|
||||
}
|
||||
|
||||
fn record_checksum(frame: Res<RollbackFrameCount>, checksum: Res<Checksum>, runtime: Option<ResMut<ReplayRuntime>>) {
|
||||
fn record_checksum(
|
||||
frame: Res<RollbackFrameCount>,
|
||||
checksum: Res<Checksum>,
|
||||
runtime: Option<ResMut<ReplayRuntime>>,
|
||||
) {
|
||||
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 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);
|
||||
error!(
|
||||
"Replay desync at frame {}: expected {:X}, got {:X}",
|
||||
frame.0, expected, checksum.0
|
||||
);
|
||||
*mismatch = true;
|
||||
}
|
||||
}
|
||||
@@ -205,7 +276,11 @@ fn stop_recording(
|
||||
finish_recording_inner(&mut commands, runtime, request);
|
||||
}
|
||||
|
||||
fn finish_recording(mut commands: Commands, runtime: Option<ResMut<ReplayRuntime>>, request: Option<Res<PendingMatch>>) {
|
||||
fn finish_recording(
|
||||
mut commands: Commands,
|
||||
runtime: Option<ResMut<ReplayRuntime>>,
|
||||
request: Option<Res<PendingMatch>>,
|
||||
) {
|
||||
finish_recording_inner(&mut commands, runtime, request);
|
||||
}
|
||||
|
||||
@@ -215,27 +290,59 @@ fn finish_recording_inner(
|
||||
request: Option<Res<PendingMatch>>,
|
||||
) {
|
||||
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 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(),
|
||||
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}"); }
|
||||
if let Err(error) = save(&path, format, &replay) {
|
||||
error!("Failed to save replay: {error}");
|
||||
}
|
||||
runtime.finished = true;
|
||||
commands.remove_resource::<ReplayRuntime>();
|
||||
}
|
||||
|
||||
fn finish_playback(mut commands: Commands, mut next_state: ResMut<NextState<AppState>>, frame: Res<RollbackFrameCount>, runtime: Option<Res<ReplayRuntime>>) {
|
||||
fn finish_playback(
|
||||
mut commands: Commands,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
frame: Res<RollbackFrameCount>,
|
||||
runtime: Option<Res<ReplayRuntime>>,
|
||||
) {
|
||||
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) {
|
||||
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::<ReplayRuntime>();
|
||||
commands.remove_resource::<LoadedReplay>();
|
||||
commands.insert_resource(ReplayComplete);
|
||||
@@ -246,7 +353,12 @@ fn finish_playback(mut commands: Commands, mut next_state: ResMut<NextState<AppS
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{fighter::FighterId, math::{int::FGi32, vec::FGVec2}, match_loading::MatchPlayer, stage::manifest::StageId};
|
||||
use crate::{
|
||||
fighter::FighterId,
|
||||
match_loading::MatchPlayer,
|
||||
math::{int::FGi32, vec::FGVec2},
|
||||
stage::manifest::StageId,
|
||||
};
|
||||
|
||||
fn sample() -> Replay {
|
||||
Replay {
|
||||
@@ -254,13 +366,22 @@ mod tests {
|
||||
game_version: "test".into(),
|
||||
match_config: ReplayMatch {
|
||||
stage: StageId::TestStage,
|
||||
players: vec![MatchPlayer { handle: 0, fighter: FighterId::TestFighter }],
|
||||
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() }],
|
||||
inputs: vec![FighterInputFrame {
|
||||
movement: FGVec2::new(FGi32::from_num(1), FGi32::ZERO),
|
||||
..default()
|
||||
}],
|
||||
}],
|
||||
checksums: vec![ReplayChecksum {
|
||||
frame: 1,
|
||||
checksum: 42,
|
||||
}],
|
||||
checksums: vec![ReplayChecksum { frame: 1, checksum: 42 }],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user