Fix heatmap issue and implement per round analysis

This commit is contained in:
Lol3rrr
2024-10-08 23:15:42 +02:00
parent 4353de4455
commit 539adecf5d
24 changed files with 58455 additions and 335 deletions

View File

@@ -12,6 +12,8 @@ colors-transform = { version = "0.2" }
serde = { version = "1.0", features = ["derive"] }
phf = { version = "0.11" }
[dev-dependencies]
pretty_assertions = { version = "1.4" }
tracing-test = { version = "0.2", features = ["no-env-filter"] }

View File

@@ -52,11 +52,32 @@ impl HeatMap {
#[derive(Debug)]
pub struct HeatMapOutput {
pub player_heatmaps: std::collections::HashMap<i32, HeatMap>,
pub entity_to_player: std::collections::HashMap<i32, csdemo::UserId>,
pub player_heatmaps: std::collections::HashMap<csdemo::UserId, HeatMap>,
pub player_info: std::collections::HashMap<csdemo::UserId, csdemo::parser::Player>,
}
#[derive(Debug)]
pub struct Team {
pub num: u32,
pub name: String,
pub players: Vec<u32>,
pub pawns: Vec<PawnID>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
pub struct PawnID(u32);
impl From<i32> for PawnID {
fn from(value: i32) -> Self {
Self((value & 0x7FF) as u32)
}
}
impl From<u32> for PawnID {
fn from(value: u32) -> Self {
Self(value & 0x7FF)
}
}
pub fn parse(config: &Config, buf: &[u8]) -> Result<HeatMapOutput, ()> {
let tmp = csdemo::Container::parse(buf).map_err(|e| ())?;
let output = csdemo::parser::parse(
@@ -66,14 +87,14 @@ pub fn parse(config: &Config, buf: &[u8]) -> Result<HeatMapOutput, ()> {
.map_err(|e| ())?;
let pawn_ids = {
let mut tmp = std::collections::HashMap::new();
let mut tmp = std::collections::HashMap::<PawnID,_>::new();
for event in output.events.iter() {
let entry = match event {
csdemo::DemoEvent::GameEvent(ge) => match ge.as_ref() {
csdemo::game_event::GameEvent::PlayerSpawn(pspawn) => match pspawn.userid_pawn.as_ref() {
Some(csdemo::RawValue::I32(v)) => {
Some((*v, pspawn.userid.unwrap()))
Some((PawnID::from(*v), pspawn.userid.unwrap()))
}
other => {
// tracing::info!("Unknown Pawn-ID: {:?}", other);
@@ -95,11 +116,8 @@ pub fn parse(config: &Config, buf: &[u8]) -> Result<HeatMapOutput, ()> {
tmp
};
tracing::debug!("Pawn-IDs: {:?}", pawn_ids);
let mut entity_id_to_user = std::collections::HashMap::<i32, csdemo::UserId>::new();
let mut player_lifestate = std::collections::HashMap::<i32, u32>::new();
let mut player_position = std::collections::HashMap::<i32, (f32, f32, f32)>::new();
let mut player_lifestate = std::collections::HashMap::<csdemo::UserId, u32>::new();
let mut player_position = std::collections::HashMap::<csdemo::UserId, (f32, f32, f32)>::new();
let mut player_cells = std::collections::HashMap::new();
let mut heatmaps = std::collections::HashMap::new();
@@ -109,7 +127,6 @@ pub fn parse(config: &Config, buf: &[u8]) -> Result<HeatMapOutput, ()> {
process_tick(
config,
tick_state,
&mut entity_id_to_user,
&pawn_ids,
&mut player_lifestate,
&mut player_position,
@@ -118,55 +135,40 @@ pub fn parse(config: &Config, buf: &[u8]) -> Result<HeatMapOutput, ()> {
);
}
tracing::debug!("Pawn-IDs: {:?}", pawn_ids);
Ok(HeatMapOutput {
player_heatmaps: heatmaps,
entity_to_player: entity_id_to_user,
player_info: output.player_info,
})
}
fn get_entityid(props: &[csdemo::parser::entities::EntityProp]) -> Option<i32> {
props.iter().find_map(|prop| {
if prop.prop_info.prop_name.as_ref() != "CCSPlayerPawn.m_nEntityId" {
return None;
}
let pawn_id: i32 = match &prop.value {
csdemo::parser::Variant::U32(v) => *v as i32,
other => panic!("Unexpected Variant: {:?}", other),
};
Some(pawn_id)
})
}
pub const MAX_COORD: f32 = (1 << 14) as f32;
fn process_tick(
config: &Config,
tick_state: &csdemo::parser::EntityTickStates,
entity_id_to_user: &mut std::collections::HashMap<i32, csdemo::UserId>,
pawn_ids: &std::collections::HashMap<i32, csdemo::UserId>,
player_lifestate: &mut std::collections::HashMap<i32, u32>,
player_position: &mut std::collections::HashMap<i32, (f32, f32, f32)>,
player_cells: &mut std::collections::HashMap<i32, (u32, u32, u32)>,
heatmaps: &mut std::collections::HashMap<i32, HeatMap>,
pawn_ids: &std::collections::HashMap<PawnID, csdemo::UserId>,
player_lifestate: &mut std::collections::HashMap<csdemo::UserId, u32>,
player_position: &mut std::collections::HashMap<csdemo::UserId, (f32, f32, f32)>,
player_cells: &mut std::collections::HashMap<csdemo::UserId, (u32, u32, u32)>,
heatmaps: &mut std::collections::HashMap<csdemo::UserId, HeatMap>,
) {
for entity_state in tick_state
.states
.iter()
.filter(|s| s.class.as_ref() == "CCSPlayerPawn")
{
if let Some(pawn_id) = get_entityid(&entity_state.props) {
let user_id = pawn_ids.get(&pawn_id).cloned().unwrap();
entity_id_to_user.insert(entity_state.id, user_id.clone());
}
let user_id = entity_state.id;
let pawn_id = PawnID::from(entity_state.id);
let user_id = match pawn_ids.get(&pawn_id).cloned() {
Some(id) => id,
None => {
continue
}
};
let _inner_guard =
tracing::trace_span!("Entity", entity_id=?entity_state.id).entered();
tracing::trace_span!("Entity", entity_id=?entity_state.id).entered();
let x_cell = match entity_state.get_prop("CCSPlayerPawn.CBodyComponentBaseAnimGraph.m_cellX").map(|prop| prop.value.as_u32()).flatten() {
Some(c) => c,
@@ -277,7 +279,7 @@ impl HeatMap {
let mut buffer = image::RgbImage::new((self.max_x - self.min_x) as u32 + 1, (self.max_y - self.min_y) as u32 + 1);
for (y, row) in self.rows.iter().rev().enumerate() {
for (x, cell) in row.iter().copied().chain(core::iter::repeat(0)).enumerate().take((self.max_x - self.min_x)) {
for (x, cell) in row.iter().copied().chain(core::iter::repeat(0)).enumerate().take(self.max_x - self.min_x) {
let scaled = (1.0/(1.0 + (cell as f32))) * 240.0;
let raw_rgb = colors_transform::Hsl::from(scaled, 100.0, 50.0).to_rgb();

View File

@@ -1,2 +1,3 @@
pub mod endofgame;
pub mod heatmap;
pub mod perround;

177
analysis/src/perround.rs Normal file
View File

@@ -0,0 +1,177 @@
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum WinReason {
StillInProgress,
BombExploded,
VipEscaped,
VipKilled,
TSaved,
CtStoppedEscape,
RoundEndReasonTerroristsStopped,
BombDefused,
TKilled,
CTKilled,
Draw,
HostageRescued,
TimeRanOut,
RoundEndReasonHostagesNotRescued,
TerroristsNotEscaped,
VipNotEscaped,
GameStart,
TSurrender,
CTSurrender,
TPlanted,
CTReachedHostage,
}
// https://github.com/markus-wa/demoinfocs-golang/blob/205b0bb25e9f3e96e1d306d154199b4a6292940e/pkg/demoinfocs/events/events.go#L53
pub static ROUND_WIN_REASON: phf::Map<i32, WinReason> = phf::phf_map! {
0_i32 => WinReason::StillInProgress,
1_i32 => WinReason::BombExploded,
2_i32 => WinReason::VipEscaped,
3_i32 => WinReason::VipKilled,
4_i32 => WinReason::TSaved,
5_i32 => WinReason::CtStoppedEscape,
6_i32 => WinReason::RoundEndReasonTerroristsStopped,
7_i32 => WinReason::BombDefused,
8_i32 => WinReason::TKilled,
9_i32 => WinReason::CTKilled,
10_i32 => WinReason::Draw,
11_i32 => WinReason::HostageRescued,
12_i32 => WinReason::TimeRanOut,
13_i32 => WinReason::RoundEndReasonHostagesNotRescued,
14_i32 => WinReason::TerroristsNotEscaped,
15_i32 => WinReason::VipNotEscaped,
16_i32 => WinReason::GameStart,
17_i32 => WinReason::TSurrender,
18_i32 => WinReason::CTSurrender,
19_i32 => WinReason::TPlanted,
20_i32 => WinReason::CTReachedHostage,
};
#[derive(Debug)]
pub struct Round {
pub winreason: WinReason,
pub start: u32,
pub end: u32,
pub events: Vec<RoundEvent>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub enum RoundEvent {
BombPlanted,
BombDefused,
Kill {
attacker: u64,
died: u64,
},
}
#[derive(Debug)]
pub struct PerRound {
pub rounds: Vec<Round>
}
pub fn parse(buf: &[u8]) -> Result<PerRound, ()> {
let tmp = csdemo::Container::parse(buf).map_err(|e| ())?;
let output = csdemo::parser::parse(
csdemo::FrameIterator::parse(tmp.inner),
csdemo::parser::EntityFilter::all()
).map_err(|e| ())?;
let mut rounds: Vec<Round> = Vec::new();
for tick in output.entity_states.ticks.iter() {
for state in tick.states.iter() {
let round_start_count = state.get_prop("CCSGameRulesProxy.CCSGameRules.m_nRoundStartCount").map(|v| v.value.as_u32()).flatten();
if let Some(round_start_count) = round_start_count {
if rounds.len() < (round_start_count - 1) as usize {
rounds.push(Round {
winreason: WinReason::StillInProgress,
start: tick.tick,
end: u32::MAX,
events: Vec::new(),
});
}
}
let round_end_count = state.get_prop("CCSGameRulesProxy.CCSGameRules.m_nRoundEndCount").map(|v| v.value.as_u32()).flatten();
if let Some(round_end_count) = round_end_count {
if rounds.len() == (round_end_count - 1) as usize {
rounds.last_mut().unwrap().end = tick.tick;
}
}
let total_rounds_played = state.get_prop("CCSGameRulesProxy.CCSGameRules.m_totalRoundsPlayed").map(|v| v.value.as_i32()).flatten();
if let Some(total_rounds_played) = total_rounds_played {
debug_assert_eq!(total_rounds_played, rounds.len() as i32);
}
if state.class.as_ref() == "CCSGameRulesProxy" {
let round_win_reason = state.get_prop("CCSGameRulesProxy.CCSGameRules.m_eRoundWinReason").map(|p| p.value.as_i32()).flatten().map(|v| ROUND_WIN_REASON.get(&v)).flatten().filter(|r| !matches!(r, WinReason::StillInProgress));
if let Some(round_win_reason) = round_win_reason {
rounds.last_mut().unwrap().winreason = round_win_reason.clone();
}
}
}
}
let mut rounds_iter = rounds.iter_mut();
let mut current_tick = 0;
let mut current_round = rounds_iter.next().unwrap();
'events: for event in output.events.iter() {
match event {
csdemo::DemoEvent::Tick(tick) => {
current_tick = tick.tick();
}
csdemo::DemoEvent::GameEvent(ge) => {
if current_tick < current_round.start {
continue;
}
while current_tick > current_round.end {
match rounds_iter.next() {
Some(r) => {
current_round = r;
}
None => break 'events,
};
}
let event = match ge.as_ref() {
csdemo::game_event::GameEvent::BombPlanted(planted) => {
RoundEvent::BombPlanted
}
csdemo::game_event::GameEvent::BombDefused(defused) => {
RoundEvent::BombDefused
}
csdemo::game_event::GameEvent::PlayerDeath(death) => {
let died = match death.userid {
Some(d) => d,
None => continue,
};
let attacker = match death.attacker.filter(|p| p.0 <= 10) {
Some(a) => a,
None => died.clone(),
};
let died_player = output.player_info.get(&died).unwrap();
let attacker_player = output.player_info.get(&attacker).unwrap();
RoundEvent::Kill {
attacker: attacker_player.xuid,
died: died_player.xuid,
}
}
_ => continue,
};
current_round.events.push(event);
}
_ => {}
};
}
Ok(PerRound {
rounds,
})
}

View File

@@ -11,8 +11,7 @@ fn heatmap_nuke() {
let config = heatmap::Config { cell_size: 5.0 };
let result = heatmap::parse(&config, &input_bytes).unwrap();
assert_eq!(result.player_heatmaps.len(), 11);
assert_eq!(result.entity_to_player.len(), 12);
assert_eq!(result.player_heatmaps.len(), 10);
}
#[test]
@@ -25,7 +24,7 @@ fn heatmap_inferno() {
let config = heatmap::Config { cell_size: 5.0 };
let result = heatmap::parse(&config, &input_bytes).unwrap();
assert_eq!(result.player_heatmaps.len(), result.player_info.len(), "Players: {:?}", result.player_heatmaps.keys().collect::<Vec<_>>());
assert_eq!(result.player_heatmaps.len(), 10);
}
#[test]
@@ -38,18 +37,5 @@ fn heatmap_dust2() {
let config = heatmap::Config { cell_size: 5.0 };
let result = heatmap::parse(&config, &input_bytes).unwrap();
assert_eq!(result.player_heatmaps.len(), result.player_info.len(), "Players: {:?}", result.player_heatmaps.keys().collect::<Vec<_>>());
assert_eq!(
result.player_info.len(),
result.entity_to_player.len(),
"Missing Entity-to-Player: {:?} - Missing Player-Info: {:?}",
result.player_heatmaps.keys().filter(|entity| !result.entity_to_player.contains_key(*entity)).collect::<Vec<_>>(),
result.player_info.iter().filter_map(|(user_id, info)| {
if result.entity_to_player.values().any(|p| p == user_id) {
return None;
}
Some(info)
}).collect::<Vec<_>>(),
);
assert_eq!(result.player_heatmaps.len(), 10);
}

View File

@@ -0,0 +1,14 @@
use analysis::perround;
use pretty_assertions::assert_eq;
#[test]
fn perround_nuke() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../testfiles/nuke.dem");
dbg!(path);
let input_bytes = std::fs::read(path).unwrap();
let result = perround::parse(&input_bytes).unwrap();
dbg!(&result);
assert_eq!(21, result.rounds.len());
}

57523
analysis/text.txt Normal file

File diff suppressed because it is too large Load Diff